@intentset/core 0.1.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/artifact.d.ts +28 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +621 -0
- package/dist/artifact.js.map +1 -0
- package/dist/carrier.d.ts +21 -0
- package/dist/carrier.d.ts.map +1 -0
- package/dist/carrier.js +70 -0
- package/dist/carrier.js.map +1 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +74 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +28 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +223 -0
- package/dist/context.js.map +1 -0
- package/dist/diagnostics.d.ts +37 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +22 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/export.d.ts +23 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +144 -0
- package/dist/export.js.map +1 -0
- package/dist/graph.d.ts +17 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +51 -0
- package/dist/graph.js.map +1 -0
- package/dist/hash.d.ts +16 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +55 -0
- package/dist/hash.js.map +1 -0
- package/dist/impact.d.ts +6 -0
- package/dist/impact.d.ts.map +1 -0
- package/dist/impact.js +89 -0
- package/dist/impact.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/registries.d.ts +19 -0
- package/dist/registries.d.ts.map +1 -0
- package/dist/registries.js +137 -0
- package/dist/registries.js.map +1 -0
- package/dist/report.d.ts +34 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +58 -0
- package/dist/report.js.map +1 -0
- package/dist/types.d.ts +266 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +90 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +6 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +573 -0
- package/dist/validate.js.map +1 -0
- package/dist/yaml.d.ts +48 -0
- package/dist/yaml.d.ts.map +1 -0
- package/dist/yaml.js +413 -0
- package/dist/yaml.js.map +1 -0
- package/package.json +36 -0
- package/src/artifact.ts +835 -0
- package/src/carrier.ts +81 -0
- package/src/config.ts +96 -0
- package/src/context.ts +240 -0
- package/src/diagnostics.ts +60 -0
- package/src/export.ts +173 -0
- package/src/graph.ts +49 -0
- package/src/hash.ts +53 -0
- package/src/impact.ts +90 -0
- package/src/index.ts +14 -0
- package/src/registries.ts +171 -0
- package/src/report.ts +84 -0
- package/src/types.ts +357 -0
- package/src/validate.ts +655 -0
- package/src/yaml.ts +406 -0
package/src/carrier.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain-Markdown carrier (Core §3, §9; ADR 0003).
|
|
3
|
+
*
|
|
4
|
+
* `plainCarrier` produces the `DocumentInput` shape from nothing but the
|
|
5
|
+
* text: the frontmatter between the `---` fences at the top of the file, and
|
|
6
|
+
* the ATX headings that lie outside fenced code blocks. The Markset adapter
|
|
7
|
+
* produces the same shape from the Markset AST; a test holds the two equal.
|
|
8
|
+
* This carrier emits no syntax diagnostics: plain Markdown has no grammar to
|
|
9
|
+
* fail, and an unclosed fence or missing frontmatter is reported by
|
|
10
|
+
* `readArtifact` as CORE001.
|
|
11
|
+
*/
|
|
12
|
+
import type { DocumentInput, Heading } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
/** Core §3 and §9: the plain-Markdown fallback carrier. */
|
|
15
|
+
export function plainCarrier(path: string, source: string): DocumentInput {
|
|
16
|
+
const text = source.startsWith("") ? source.slice(1) : source;
|
|
17
|
+
const lines = text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
|
|
18
|
+
const frontmatter = findFrontmatter(lines);
|
|
19
|
+
const bodyStart = frontmatter === null ? 0 : frontmatter.close + 1;
|
|
20
|
+
return {
|
|
21
|
+
path,
|
|
22
|
+
source,
|
|
23
|
+
frontmatter: frontmatter === null ? null : { text: frontmatter.text, line: 2 },
|
|
24
|
+
headings: collectHeadings(lines, bodyStart),
|
|
25
|
+
syntax: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Core §3: the 0-based index of the closing frontmatter fence of a document that starts with one, or -1. */
|
|
30
|
+
export function closingFenceIndex(lines: readonly string[]): number {
|
|
31
|
+
if (lines.length === 0 || lines[0].trimEnd() !== "---") return -1;
|
|
32
|
+
for (let i = 1; i < lines.length; i++) {
|
|
33
|
+
if (lines[i].trimEnd() === "---") return i;
|
|
34
|
+
}
|
|
35
|
+
return -1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findFrontmatter(lines: string[]): { text: string; close: number } | null {
|
|
39
|
+
const close = closingFenceIndex(lines);
|
|
40
|
+
if (close === -1) return null;
|
|
41
|
+
return { text: lines.slice(1, close).join("\n"), close };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
45
|
+
const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/;
|
|
46
|
+
|
|
47
|
+
/** Core §6 and ADR 0003: ATX headings outside fenced code blocks, with inline markup removed. */
|
|
48
|
+
export function collectHeadings(lines: readonly string[], from: number): Heading[] {
|
|
49
|
+
const headings: Heading[] = [];
|
|
50
|
+
let fence: { char: string; length: number } | null = null;
|
|
51
|
+
for (let i = from; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
if (fence !== null) {
|
|
54
|
+
const close = new RegExp(`^ {0,3}\\${fence.char}{${fence.length},}[ \\t]*$`);
|
|
55
|
+
if (close.test(line)) fence = null;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const open = FENCE_OPEN.exec(line);
|
|
59
|
+
if (open !== null && !(open[1][0] === "`" && open[2].includes("`"))) {
|
|
60
|
+
fence = { char: open[1][0], length: open[1].length };
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const atx = ATX.exec(line);
|
|
64
|
+
if (atx === null) continue;
|
|
65
|
+
headings.push({ depth: atx[1].length, text: headingText(atx[2] ?? ""), line: i + 1 });
|
|
66
|
+
}
|
|
67
|
+
return headings;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Core §6 and ADR 0003: the plain text of a heading, closing `#`s, emphasis, code spans and link brackets removed. */
|
|
71
|
+
export function headingText(raw: string): string {
|
|
72
|
+
let text = raw.trim();
|
|
73
|
+
if (/^#+$/.test(text)) return "";
|
|
74
|
+
text = text.replace(/[ \t]+#+$/, "");
|
|
75
|
+
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
76
|
+
text = text.replace(/\[([^\]]*)\]/g, "$1");
|
|
77
|
+
text = text.replace(/[*`]/g, "");
|
|
78
|
+
text = text.replace(/(^|\s)_+|_+(?=\s|$)/g, "$1");
|
|
79
|
+
text = text.replace(/\\([\\`*_{}[\]()#+\-.!])/g, "$1");
|
|
80
|
+
return text.replace(/\s+/g, " ").trim();
|
|
81
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.intentset/config.yaml` (Core §11: a conformance claim names its declared
|
|
3
|
+
* repository scope). `repository` is the stable identity the export carries;
|
|
4
|
+
* `scope` is the globs that select documents; `registries` is the path of
|
|
5
|
+
* registries.yaml or null; `ignore` excludes generated output. A bad shape is
|
|
6
|
+
* CFG001 and the defaults stand in, so a caller always gets a usable config.
|
|
7
|
+
*/
|
|
8
|
+
import type { Diagnostic } from "./diagnostics.ts";
|
|
9
|
+
import { compareStrings, makeDiagnostic } from "./report.ts";
|
|
10
|
+
import { patternProblem } from "./validate.ts";
|
|
11
|
+
import type { Config } from "./types.ts";
|
|
12
|
+
import { parseYamlDetailed } from "./yaml.ts";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_SCOPE = ["**/*.md"];
|
|
15
|
+
export const DEFAULT_IGNORE = ["node_modules/**", "dist/**", ".git/**"];
|
|
16
|
+
const KNOWN_KEYS = ["repository", "scope", "registries", "ignore"];
|
|
17
|
+
|
|
18
|
+
/** Core §11: read the repository configuration. Shape problems are CFG001. */
|
|
19
|
+
export function readConfig(text: string, path: string): { config: Config; diagnostics: Diagnostic[] } {
|
|
20
|
+
const diagnostics: Diagnostic[] = [];
|
|
21
|
+
const config: Config = { repository: "", scope: [...DEFAULT_SCOPE], registries: null, ignore: [...DEFAULT_IGNORE] };
|
|
22
|
+
const parsed = parseYamlDetailed(text);
|
|
23
|
+
if (parsed.error) {
|
|
24
|
+
diagnostics.push(
|
|
25
|
+
makeDiagnostic({
|
|
26
|
+
code: "CFG001",
|
|
27
|
+
origin: "profile",
|
|
28
|
+
artifact: null,
|
|
29
|
+
path,
|
|
30
|
+
location: { line: parsed.error.line },
|
|
31
|
+
message: `The configuration does not parse: ${parsed.error.message}`,
|
|
32
|
+
remediation: "Write the configuration as a block mapping with repository, scope, registries and ignore.",
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
return { config, diagnostics };
|
|
36
|
+
}
|
|
37
|
+
const root = parsed.value;
|
|
38
|
+
const report = (field: string, message: string, remediation: string) => {
|
|
39
|
+
const line = parsed.lines.get(field);
|
|
40
|
+
diagnostics.push(
|
|
41
|
+
makeDiagnostic({
|
|
42
|
+
code: "CFG001",
|
|
43
|
+
origin: "profile",
|
|
44
|
+
artifact: null,
|
|
45
|
+
path,
|
|
46
|
+
...(line !== undefined ? { location: { line } } : {}),
|
|
47
|
+
field,
|
|
48
|
+
message,
|
|
49
|
+
remediation,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
for (const key of Object.keys(root).sort(compareStrings)) {
|
|
55
|
+
if (!KNOWN_KEYS.includes(key)) {
|
|
56
|
+
report(`/${key}`, `Unknown configuration key \`${key}\`.`, `Remove it; the keys are ${KNOWN_KEYS.join(", ")}.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (typeof root.repository === "string" && root.repository !== "") {
|
|
60
|
+
config.repository = root.repository;
|
|
61
|
+
} else {
|
|
62
|
+
report(
|
|
63
|
+
"/repository",
|
|
64
|
+
"`repository` is required and must be a non-empty string.",
|
|
65
|
+
'Set it to the repository\'s stable identity, such as "org/name".',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
for (const key of ["scope", "ignore"] as const) {
|
|
69
|
+
const value = root[key];
|
|
70
|
+
if (value === undefined || value === null) continue;
|
|
71
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "")) {
|
|
72
|
+
config[key] = [...(value as string[])];
|
|
73
|
+
for (const [index, pattern] of (value as string[]).entries()) {
|
|
74
|
+
const why = patternProblem(pattern);
|
|
75
|
+
if (why !== null) {
|
|
76
|
+
report(
|
|
77
|
+
`/${key}/${index}`,
|
|
78
|
+
`${key} pattern ${JSON.stringify(pattern)} is not a supported pattern: ${why}`,
|
|
79
|
+
"Use repository-relative POSIX paths with literal segments, * within a segment and ** across segments.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
report(
|
|
85
|
+
`/${key}`,
|
|
86
|
+
`\`${key}\` must be a list of glob strings.`,
|
|
87
|
+
`Write ${key} as a sequence of patterns such as "**/*.md".`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (root.registries !== undefined && root.registries !== null) {
|
|
92
|
+
if (typeof root.registries === "string" && root.registries !== "") config.registries = root.registries;
|
|
93
|
+
else report("/registries", "`registries` must be a path or null.", "Point it at registries.yaml, or remove it.");
|
|
94
|
+
}
|
|
95
|
+
return { config, diagnostics };
|
|
96
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent context (Core §10): the bounded set of artifacts an engineer or an
|
|
3
|
+
* agent loads before changing implementation, starting from one ID.
|
|
4
|
+
*
|
|
5
|
+
* behavior its owning slices (the derived side of `implements`), the
|
|
6
|
+
* behavior, its rules (`governedBy`), the scenarios that
|
|
7
|
+
* illustrate it, the verifications over it, its rules and its
|
|
8
|
+
* scenarios, the owning slices' exposed and consumed contracts,
|
|
9
|
+
* the decisions informing the behavior, the slices and the
|
|
10
|
+
* contracts, the knowledge explaining it, and its parent chain
|
|
11
|
+
* rule the behaviors it governs, and from each of their bundles the
|
|
12
|
+
* owning slices and the verifications; also the verifications
|
|
13
|
+
* and knowledge naming the rule itself, so a rule no behavior
|
|
14
|
+
* governs yet still shows what checks and explains it
|
|
15
|
+
* capability the bundles of its behaviors (children through `parent`,
|
|
16
|
+
* through sub-capabilities, with a visited set), merged, plus
|
|
17
|
+
* the knowledge explaining it and its own parent chain
|
|
18
|
+
* slice the bundles of the behaviors it implements, merged, plus its
|
|
19
|
+
* own contracts and the decisions informing it and them
|
|
20
|
+
* otherwise the artifact and its direct neighbours, authored and derived
|
|
21
|
+
*
|
|
22
|
+
* Each artifact appears once, under the first role it has in role order, and
|
|
23
|
+
* the bundle is sorted by role then ID. `restricted` artifacts, the start
|
|
24
|
+
* included, are withheld and counted unless asked for, so a short context
|
|
25
|
+
* never reads as a complete one. Only IDs in the graph are followed; an
|
|
26
|
+
* unresolved edge is the validator's to report. Pure and deterministic: the
|
|
27
|
+
* result depends on the graph's artifacts and edges, not on file order.
|
|
28
|
+
*/
|
|
29
|
+
import { compareStrings } from "./report.ts";
|
|
30
|
+
import type { ArtifactType, Graph, LinkKind, Status, Visibility } from "./types.ts";
|
|
31
|
+
|
|
32
|
+
/** The roles an artifact can have in a bundle, in the order a bundle is sorted by. */
|
|
33
|
+
export const CONTEXT_ROLES = [
|
|
34
|
+
"start",
|
|
35
|
+
"owner",
|
|
36
|
+
"behavior",
|
|
37
|
+
"rule",
|
|
38
|
+
"scenario",
|
|
39
|
+
"verification",
|
|
40
|
+
"contract",
|
|
41
|
+
"decision",
|
|
42
|
+
"knowledge",
|
|
43
|
+
"ancestor",
|
|
44
|
+
"neighbour",
|
|
45
|
+
] as const;
|
|
46
|
+
export type ContextRole = (typeof CONTEXT_ROLES)[number];
|
|
47
|
+
|
|
48
|
+
export interface ContextArtifact {
|
|
49
|
+
id: string;
|
|
50
|
+
type: ArtifactType;
|
|
51
|
+
title: string;
|
|
52
|
+
status: Status;
|
|
53
|
+
visibility: Visibility;
|
|
54
|
+
/** Repository-relative path of the artifact's file. */
|
|
55
|
+
path: string;
|
|
56
|
+
role: ContextRole;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ContextBundle {
|
|
60
|
+
start: string;
|
|
61
|
+
/** Sorted by role order, then ID. Each artifact once, under the first role it has in that order. */
|
|
62
|
+
artifacts: ContextArtifact[];
|
|
63
|
+
/** Restricted artifacts left out because they were not asked for. */
|
|
64
|
+
withheld: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ContextOptions {
|
|
68
|
+
/** Include `restricted` artifacts instead of withholding them. Default false. */
|
|
69
|
+
includeRestricted?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const RANK = new Map<ContextRole, number>(CONTEXT_ROLES.map((role, index) => [role, index]));
|
|
73
|
+
|
|
74
|
+
/** Core §10: the bounded context of an artifact, or null when the graph has no artifact with this ID. */
|
|
75
|
+
export function contextFor(graph: Graph, id: string, options: ContextOptions = {}): ContextBundle | null {
|
|
76
|
+
const start = graph.artifacts.get(id);
|
|
77
|
+
if (start === undefined) return null;
|
|
78
|
+
const roles = new Roles(graph);
|
|
79
|
+
roles.add(id, "start");
|
|
80
|
+
|
|
81
|
+
switch (start.meta.type) {
|
|
82
|
+
case "behavior":
|
|
83
|
+
behaviorBundle(graph, id, roles);
|
|
84
|
+
break;
|
|
85
|
+
case "rule":
|
|
86
|
+
for (const behavior of into(graph, id, "governedBy", "behavior")) {
|
|
87
|
+
roles.add(behavior, "behavior");
|
|
88
|
+
const bundle = new Roles(graph);
|
|
89
|
+
behaviorBundle(graph, behavior, bundle);
|
|
90
|
+
for (const [member, role] of bundle.entries()) {
|
|
91
|
+
if (role === "owner" || role === "verification") roles.add(member, role);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const verification of into(graph, id, "verifies", "verification")) roles.add(verification, "verification");
|
|
95
|
+
for (const knowledge of into(graph, id, "explains", "knowledge")) roles.add(knowledge, "knowledge");
|
|
96
|
+
break;
|
|
97
|
+
case "capability":
|
|
98
|
+
for (const behavior of behaviorsUnder(graph, id)) behaviorBundle(graph, behavior, roles);
|
|
99
|
+
for (const knowledge of into(graph, id, "explains", "knowledge")) roles.add(knowledge, "knowledge");
|
|
100
|
+
for (const ancestor of ancestors(graph, id)) roles.add(ancestor, "ancestor");
|
|
101
|
+
break;
|
|
102
|
+
case "slice": {
|
|
103
|
+
for (const behavior of from(graph, id, "implements", "behavior")) behaviorBundle(graph, behavior, roles);
|
|
104
|
+
const contracts = [...from(graph, id, "exposes", "contract"), ...from(graph, id, "consumes", "contract")];
|
|
105
|
+
for (const contract of contracts) roles.add(contract, "contract");
|
|
106
|
+
for (const source of [id, ...contracts]) {
|
|
107
|
+
for (const decision of from(graph, source, "informedBy", "decision")) roles.add(decision, "decision");
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
default:
|
|
112
|
+
for (const edge of graph.out.get(id) ?? []) roles.add(edge.to, "neighbour");
|
|
113
|
+
for (const edge of graph.in.get(id) ?? []) roles.add(edge.from, "neighbour");
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const artifacts: ContextArtifact[] = [];
|
|
118
|
+
let withheld = 0;
|
|
119
|
+
for (const [member, role] of roles.entries()) {
|
|
120
|
+
const artifact = graph.artifacts.get(member);
|
|
121
|
+
if (artifact === undefined) continue;
|
|
122
|
+
const { meta } = artifact;
|
|
123
|
+
if (meta.visibility === "restricted" && options.includeRestricted !== true) {
|
|
124
|
+
withheld++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
artifacts.push({
|
|
128
|
+
id: meta.id,
|
|
129
|
+
type: meta.type,
|
|
130
|
+
title: meta.title,
|
|
131
|
+
status: meta.status,
|
|
132
|
+
visibility: meta.visibility,
|
|
133
|
+
path: artifact.path,
|
|
134
|
+
role,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
artifacts.sort((a, b) => rank(a.role) - rank(b.role) || compareStrings(a.id, b.id));
|
|
138
|
+
return { start: id, artifacts, withheld };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Role assignment with "first role in role order wins", over IDs that are in the graph. */
|
|
142
|
+
class Roles {
|
|
143
|
+
readonly #graph: Graph;
|
|
144
|
+
readonly #roles = new Map<string, ContextRole>();
|
|
145
|
+
|
|
146
|
+
constructor(graph: Graph) {
|
|
147
|
+
this.#graph = graph;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
add(id: string, role: ContextRole): void {
|
|
151
|
+
if (!this.#graph.artifacts.has(id)) return;
|
|
152
|
+
const previous = this.#roles.get(id);
|
|
153
|
+
if (previous === undefined || rank(role) < rank(previous)) this.#roles.set(id, role);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Entries sorted by ID, so nothing downstream depends on insertion order. */
|
|
157
|
+
entries(): [string, ContextRole][] {
|
|
158
|
+
return [...this.#roles.entries()].sort((a, b) => compareStrings(a[0], b[0]));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function rank(role: ContextRole): number {
|
|
163
|
+
return RANK.get(role) ?? CONTEXT_ROLES.length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One behavior's bundle, added into `roles`. */
|
|
167
|
+
function behaviorBundle(graph: Graph, behavior: string, roles: Roles): void {
|
|
168
|
+
roles.add(behavior, "behavior");
|
|
169
|
+
const owners = into(graph, behavior, "implements", "slice");
|
|
170
|
+
const rules = from(graph, behavior, "governedBy", "rule");
|
|
171
|
+
const scenarios = into(graph, behavior, "illustrates", "scenario");
|
|
172
|
+
const contracts = owners.flatMap((slice) => [
|
|
173
|
+
...from(graph, slice, "exposes", "contract"),
|
|
174
|
+
...from(graph, slice, "consumes", "contract"),
|
|
175
|
+
]);
|
|
176
|
+
for (const slice of owners) roles.add(slice, "owner");
|
|
177
|
+
for (const rule of rules) roles.add(rule, "rule");
|
|
178
|
+
for (const scenario of scenarios) roles.add(scenario, "scenario");
|
|
179
|
+
for (const claim of [behavior, ...rules, ...scenarios]) {
|
|
180
|
+
for (const verification of into(graph, claim, "verifies", "verification")) roles.add(verification, "verification");
|
|
181
|
+
}
|
|
182
|
+
for (const contract of contracts) roles.add(contract, "contract");
|
|
183
|
+
for (const source of [behavior, ...owners, ...contracts]) {
|
|
184
|
+
for (const decision of from(graph, source, "informedBy", "decision")) roles.add(decision, "decision");
|
|
185
|
+
}
|
|
186
|
+
for (const knowledge of into(graph, behavior, "explains", "knowledge")) roles.add(knowledge, "knowledge");
|
|
187
|
+
for (const ancestor of ancestors(graph, behavior)) roles.add(ancestor, "ancestor");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Behaviors whose navigation parent is the capability or one of its sub-capabilities, with a visited set. */
|
|
191
|
+
function behaviorsUnder(graph: Graph, capability: string): string[] {
|
|
192
|
+
const behaviors = new Set<string>();
|
|
193
|
+
const seen = new Set<string>([capability]);
|
|
194
|
+
const queue = [capability];
|
|
195
|
+
while (queue.length > 0) {
|
|
196
|
+
const current = queue.shift() as string;
|
|
197
|
+
for (const child of into(graph, current, "parent")) {
|
|
198
|
+
const type = graph.artifacts.get(child)?.meta.type;
|
|
199
|
+
if (type === "behavior") behaviors.add(child);
|
|
200
|
+
else if (type === "capability" && !seen.has(child)) {
|
|
201
|
+
seen.add(child);
|
|
202
|
+
queue.push(child);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return [...behaviors].sort(compareStrings);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The navigation parent chain, nearest first, stopping at a cycle or an ID not in the graph. */
|
|
210
|
+
function ancestors(graph: Graph, id: string): string[] {
|
|
211
|
+
const chain: string[] = [];
|
|
212
|
+
const visited = new Set<string>([id]);
|
|
213
|
+
let cursor = graph.artifacts.get(id)?.meta.parent;
|
|
214
|
+
while (cursor !== undefined && !visited.has(cursor) && graph.artifacts.has(cursor)) {
|
|
215
|
+
visited.add(cursor);
|
|
216
|
+
chain.push(cursor);
|
|
217
|
+
cursor = graph.artifacts.get(cursor)?.meta.parent;
|
|
218
|
+
}
|
|
219
|
+
return chain;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Targets of authored edges of one kind leaving `id`, in the graph and of the given type, sorted and unique. */
|
|
223
|
+
function from(graph: Graph, id: string, kind: LinkKind, type?: ArtifactType): string[] {
|
|
224
|
+
const ids = (graph.out.get(id) ?? []).filter((edge) => edge.kind === kind).map((edge) => edge.to);
|
|
225
|
+
return typed(graph, ids, type);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Sources of authored edges of one kind arriving at `id`: the derived side, read from the authored edge. */
|
|
229
|
+
function into(graph: Graph, id: string, kind: LinkKind, type?: ArtifactType): string[] {
|
|
230
|
+
const ids = (graph.in.get(id) ?? []).filter((edge) => edge.kind === kind).map((edge) => edge.from);
|
|
231
|
+
return typed(graph, ids, type);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function typed(graph: Graph, ids: string[], type: ArtifactType | undefined): string[] {
|
|
235
|
+
const kept = ids.filter((each) => {
|
|
236
|
+
const artifact = graph.artifacts.get(each);
|
|
237
|
+
return artifact !== undefined && (type === undefined || artifact.meta.type === type);
|
|
238
|
+
});
|
|
239
|
+
return [...new Set(kept)].sort(compareStrings);
|
|
240
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One diagnostic shape across every package (ADR 0004, Core §11).
|
|
3
|
+
*
|
|
4
|
+
* `location` is present only when known; a missing location stays missing
|
|
5
|
+
* rather than becoming an invented coordinate. `field` is a JSON pointer into
|
|
6
|
+
* the frontmatter when the problem is a frontmatter value.
|
|
7
|
+
*/
|
|
8
|
+
export type Severity = "error" | "warning";
|
|
9
|
+
|
|
10
|
+
export type Origin = "syntax" | "profile" | "graph" | "architecture" | "evidence" | "publication" | "render";
|
|
11
|
+
|
|
12
|
+
export interface Location {
|
|
13
|
+
/** 1-based line in the file named by `path`. */
|
|
14
|
+
line: number;
|
|
15
|
+
/** 1-based column, when known. */
|
|
16
|
+
column?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Diagnostic {
|
|
20
|
+
/** Stable code, e.g. CORE003, VSA005, TS003, AMP001. */
|
|
21
|
+
code: string;
|
|
22
|
+
severity: Severity;
|
|
23
|
+
origin: Origin;
|
|
24
|
+
/** Artifact ID the problem belongs to, or null when the file has no readable ID. */
|
|
25
|
+
artifact: string | null;
|
|
26
|
+
/** Repository-relative POSIX path, or null for a repository-wide problem. */
|
|
27
|
+
path: string | null;
|
|
28
|
+
location?: Location;
|
|
29
|
+
/** JSON pointer into the frontmatter, e.g. "/intentset/links/governedBy/0". */
|
|
30
|
+
field?: string;
|
|
31
|
+
message: string;
|
|
32
|
+
remediation: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** ADR 0004: true when any diagnostic is an error; warnings never fail a check. */
|
|
36
|
+
export function hasErrors(diagnostics: readonly Diagnostic[]): boolean {
|
|
37
|
+
return diagnostics.some((d) => d.severity === "error");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Core §11 order: path, artifact ID, code; then line so output is total and deterministic. */
|
|
41
|
+
export function compareDiagnostics(a: Diagnostic, b: Diagnostic): number {
|
|
42
|
+
return (
|
|
43
|
+
cmp(a.path ?? "", b.path ?? "") ||
|
|
44
|
+
cmp(a.artifact ?? "", b.artifact ?? "") ||
|
|
45
|
+
cmp(a.code, b.code) ||
|
|
46
|
+
(a.location?.line ?? 0) - (b.location?.line ?? 0) ||
|
|
47
|
+
(a.location?.column ?? 0) - (b.location?.column ?? 0) ||
|
|
48
|
+
cmp(a.field ?? "", b.field ?? "") ||
|
|
49
|
+
cmp(a.message, b.message)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Core §11 and ADR 0004: a sorted copy, in `compareDiagnostics` order. */
|
|
54
|
+
export function sortDiagnostics<T extends Diagnostic>(diagnostics: readonly T[]): T[] {
|
|
55
|
+
return [...diagnostics].sort(compareDiagnostics);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cmp(a: string, b: string): number {
|
|
59
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
60
|
+
}
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The export envelope (Core §12; the integration contract; spec/export.schema.json).
|
|
3
|
+
* Artifacts are sorted by ID and every list inside is sorted, so the same
|
|
4
|
+
* validation result with the same `generatedAt` is the same bytes. Bodies
|
|
5
|
+
* are included only when asked for.
|
|
6
|
+
*/
|
|
7
|
+
import { graphHash, sortKeysDeep } from "./hash.ts";
|
|
8
|
+
import { compareStrings } from "./report.ts";
|
|
9
|
+
import {
|
|
10
|
+
type Availability,
|
|
11
|
+
EXPORT_CONTRACT,
|
|
12
|
+
type ExportArtifact,
|
|
13
|
+
type ExportEnvelope,
|
|
14
|
+
LINK_KINDS,
|
|
15
|
+
type Level,
|
|
16
|
+
type LinkKind,
|
|
17
|
+
type Registries,
|
|
18
|
+
SPEC_VERSION,
|
|
19
|
+
type SliceMeta,
|
|
20
|
+
type ValidationResult,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
|
|
23
|
+
/** What the caller knows and the validation result does not: identity, commit, scope, level, release. */
|
|
24
|
+
export interface ExportMeta {
|
|
25
|
+
/** Stable identity of the repository, from config. */
|
|
26
|
+
repository: string;
|
|
27
|
+
/** The commit the export describes, or null with `commitUnavailable` saying why (a default reason is written when none is given). */
|
|
28
|
+
commit: string | null;
|
|
29
|
+
commitUnavailable?: string;
|
|
30
|
+
/** The scope globs validated; defaults to every Markdown file. */
|
|
31
|
+
scope?: string[];
|
|
32
|
+
/** The level the validation ran at; defaults to L1. */
|
|
33
|
+
level?: Level;
|
|
34
|
+
release?: { product: string; label: string } | null;
|
|
35
|
+
/** ISO 8601; defaults to now. Fix it to make two exports comparable byte for byte. */
|
|
36
|
+
generatedAt?: string;
|
|
37
|
+
includeBodies?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Core §12 and the integration contract: the `intentset/export/0.1` envelope over a validation result. */
|
|
41
|
+
export function exportGraph(result: ValidationResult, registries: Registries, meta: ExportMeta): ExportEnvelope {
|
|
42
|
+
const { graph } = result;
|
|
43
|
+
const ids = [...graph.artifacts.keys()].sort(compareStrings);
|
|
44
|
+
const errors = result.diagnostics.filter((d) => d.severity === "error").length;
|
|
45
|
+
const warnings = result.diagnostics.length - errors;
|
|
46
|
+
const source: ExportEnvelope["source"] = { commit: meta.commit };
|
|
47
|
+
if (meta.commit === null)
|
|
48
|
+
source.commitUnavailable = meta.commitUnavailable ?? "No commit was supplied to the export.";
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
contract: EXPORT_CONTRACT,
|
|
52
|
+
spec: SPEC_VERSION,
|
|
53
|
+
generatedAt: meta.generatedAt ?? new Date().toISOString(),
|
|
54
|
+
repository: meta.repository,
|
|
55
|
+
products: ids.filter((id) => graph.artifacts.get(id)?.meta.type === "product"),
|
|
56
|
+
source,
|
|
57
|
+
graphHash: graphHash(graph),
|
|
58
|
+
release: meta.release ?? null,
|
|
59
|
+
validation: {
|
|
60
|
+
level: meta.level ?? "L1",
|
|
61
|
+
scope: [...(meta.scope ?? ["**/*.md"])],
|
|
62
|
+
status: result.ok ? "pass" : "fail",
|
|
63
|
+
errors,
|
|
64
|
+
warnings,
|
|
65
|
+
diagnostics: [...result.diagnostics],
|
|
66
|
+
},
|
|
67
|
+
registries: exportRegistries(registries),
|
|
68
|
+
artifacts: ids.map((id) => exportArtifact(result, id, meta.includeBodies === true)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function exportArtifact(result: ValidationResult, id: string, includeBody: boolean): ExportArtifact {
|
|
73
|
+
const { graph } = result;
|
|
74
|
+
const artifact = graph.artifacts.get(id);
|
|
75
|
+
if (artifact === undefined) throw new Error(`graph has no artifact ${id}`);
|
|
76
|
+
const { meta } = artifact;
|
|
77
|
+
|
|
78
|
+
const links: ExportArtifact["links"] = {};
|
|
79
|
+
for (const kind of LINK_KINDS) {
|
|
80
|
+
if (kind === "parent") continue;
|
|
81
|
+
const list = meta.links[kind];
|
|
82
|
+
if (list !== undefined) links[kind] = [...list].sort(compareStrings);
|
|
83
|
+
}
|
|
84
|
+
const derived: ExportArtifact["derived"] = {};
|
|
85
|
+
const byKind = new Map<LinkKind, string[]>();
|
|
86
|
+
for (const edge of graph.in.get(id) ?? []) {
|
|
87
|
+
const list = byKind.get(edge.kind);
|
|
88
|
+
if (list === undefined) byKind.set(edge.kind, [edge.from]);
|
|
89
|
+
else list.push(edge.from);
|
|
90
|
+
}
|
|
91
|
+
for (const kind of LINK_KINDS) {
|
|
92
|
+
const list = byKind.get(kind);
|
|
93
|
+
if (list !== undefined) derived[kind] = [...new Set(list)].sort(compareStrings);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const exported: ExportArtifact = {
|
|
97
|
+
id,
|
|
98
|
+
type: meta.type,
|
|
99
|
+
title: meta.title,
|
|
100
|
+
status: meta.status,
|
|
101
|
+
owner: meta.owner,
|
|
102
|
+
visibility: meta.visibility,
|
|
103
|
+
audiences: [...meta.audiences].sort(compareStrings),
|
|
104
|
+
profile: meta.profile,
|
|
105
|
+
revision: meta.revision ?? null,
|
|
106
|
+
path: artifact.path,
|
|
107
|
+
sourceHash: artifact.sourceHash,
|
|
108
|
+
parent: meta.parent ?? null,
|
|
109
|
+
links,
|
|
110
|
+
derived,
|
|
111
|
+
availability: meta.availability === undefined ? null : sortedAvailability(meta.availability),
|
|
112
|
+
slice: meta.slice === undefined ? null : sortedSlice(meta.slice),
|
|
113
|
+
verification:
|
|
114
|
+
meta.verification === undefined
|
|
115
|
+
? null
|
|
116
|
+
: {
|
|
117
|
+
method: meta.verification.method,
|
|
118
|
+
locator: meta.verification.locator,
|
|
119
|
+
selector: meta.verification.selector,
|
|
120
|
+
},
|
|
121
|
+
extensions: meta.extensions === undefined ? null : (sortKeysDeep(meta.extensions) as Record<string, unknown>),
|
|
122
|
+
};
|
|
123
|
+
if (includeBody) exported.body = artifact.body;
|
|
124
|
+
return exported;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sortedAvailability(availability: Availability): Availability {
|
|
128
|
+
return {
|
|
129
|
+
products: [...availability.products].sort(compareStrings),
|
|
130
|
+
releases: [...availability.releases].sort(compareStrings),
|
|
131
|
+
roles: [...availability.roles].sort(compareStrings),
|
|
132
|
+
editions: [...availability.editions].sort(compareStrings),
|
|
133
|
+
flags: [...availability.flags].sort(compareStrings),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sortedSlice(slice: SliceMeta): SliceMeta {
|
|
138
|
+
const layers: Record<string, string[]> = {};
|
|
139
|
+
for (const layer of Object.keys(slice.layers).sort(compareStrings)) {
|
|
140
|
+
layers[layer] = [...slice.layers[layer]].sort(compareStrings);
|
|
141
|
+
}
|
|
142
|
+
const out: SliceMeta = {
|
|
143
|
+
kind: slice.kind,
|
|
144
|
+
domain: slice.domain,
|
|
145
|
+
entrypoints: [...slice.entrypoints].sort(compareStrings),
|
|
146
|
+
layers,
|
|
147
|
+
claims: [...slice.claims]
|
|
148
|
+
.map((claim) => ({ kind: claim.kind, path: claim.path }))
|
|
149
|
+
.sort((a, b) => compareStrings(a.kind, b.kind) || compareStrings(a.path, b.path)),
|
|
150
|
+
usesResources: [...slice.usesResources].sort(compareStrings),
|
|
151
|
+
};
|
|
152
|
+
if (slice.rationale !== undefined) out.rationale = slice.rationale;
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function exportRegistries(registries: Registries): Registries {
|
|
157
|
+
return {
|
|
158
|
+
owners: [...registries.owners].sort(compareStrings),
|
|
159
|
+
audiences: [...registries.audiences].sort(compareStrings),
|
|
160
|
+
releases: [...registries.releases].sort(compareStrings),
|
|
161
|
+
roles: [...registries.roles].sort(compareStrings),
|
|
162
|
+
editions: [...registries.editions].sort(compareStrings),
|
|
163
|
+
flags: [...registries.flags].sort(compareStrings),
|
|
164
|
+
resources: [...registries.resources]
|
|
165
|
+
.map((resource) => ({
|
|
166
|
+
id: resource.id,
|
|
167
|
+
path: resource.path,
|
|
168
|
+
owner: resource.owner,
|
|
169
|
+
consumers: [...resource.consumers].sort(compareStrings),
|
|
170
|
+
}))
|
|
171
|
+
.sort((a, b) => compareStrings(a.id, b.id)),
|
|
172
|
+
};
|
|
173
|
+
}
|