@open-agent-toolkit/cli 0.2.25 → 0.2.27
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/assets/NOTICES.md +156 -0
- package/assets/docs/cli-utilities/configuration.md +42 -11
- package/assets/docs/contributing/explainer-kit-verification.md +125 -0
- package/assets/docs/contributing/index.md +1 -0
- package/assets/docs/reference/troubleshooting.md +47 -0
- package/assets/docs/workflows/projects/artifacts.md +24 -6
- package/assets/docs/workflows/projects/dispatch-ceiling.md +67 -16
- package/assets/docs/workflows/skills/explainer-kit-providers.md +144 -0
- package/assets/docs/workflows/skills/explainer-kit.md +121 -69
- package/assets/docs/workflows/skills/index.md +1 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +18 -3
- package/assets/skills/explainer-kit/recipes/project-recap.json +43 -16
- package/assets/skills/explainer-kit/references/contracts.md +167 -20
- package/assets/skills/explainer-kit/references/golden-conformance.md +80 -0
- package/assets/skills/explainer-kit/references/visual-authoring.md +92 -0
- package/assets/skills/explainer-kit/references/visual-review.md +57 -0
- package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +172 -1
- package/assets/skills/explainer-kit/schemas/build-record.schema.json +7 -1
- package/assets/skills/explainer-kit/schemas/fact-base.schema.json +38 -2
- package/assets/skills/explainer-kit/schemas/manifest.schema.json +25 -1
- package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -0
- package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +149 -0
- package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +117 -0
- package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +80 -0
- package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +148 -4
- package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +243 -0
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +586 -8
- package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +285 -8
- package/assets/skills/explainer-kit/scripts/lib/durability.mjs +35 -0
- package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +144 -8
- package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +379 -0
- package/assets/skills/explainer-kit/scripts/lib/png.mjs +287 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +280 -8
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +132 -3
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +513 -21
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +67 -3
- package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +43 -1
- package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +208 -0
- package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +218 -0
- package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +380 -0
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +48 -10
- package/assets/skills/explainer-kit/scripts/run.mjs +859 -134
- package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
- package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
- package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
- package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
- package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-implement/SKILL.md +9 -11
- package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +18 -9
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-implement/references/phase-execution.md +13 -4
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +27 -3
- package/dist/commands/project/archive/archive-utils.d.ts +1 -0
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +109 -42
- package/dist/commands/project/archive/explainer-package-coverage.d.ts +14 -0
- package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -0
- package/dist/commands/project/archive/explainer-package-coverage.js +27 -0
- package/dist/commands/project/archive/explainer-source-backlinks.d.ts +18 -0
- package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +1 -0
- package/dist/commands/project/archive/explainer-source-backlinks.js +27 -0
- package/dist/commands/project/archive/push-runner.d.ts +2 -1
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +5 -1
- package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
- package/dist/commands/project/dispatch-ceiling/index.js +90 -0
- package/dist/config/dispatch-notices.d.ts +8 -0
- package/dist/config/dispatch-notices.d.ts.map +1 -0
- package/dist/config/dispatch-notices.js +79 -0
- package/dist/config/dispatch-policy-options.d.ts +2 -0
- package/dist/config/dispatch-policy-options.d.ts.map +1 -1
- package/dist/config/dispatch-policy-options.js +14 -2
- package/dist/providers/identity/dispatch-report.d.ts +17 -0
- package/dist/providers/identity/dispatch-report.d.ts.map +1 -1
- package/dist/providers/identity/dispatch-report.js +30 -0
- package/dist/release/public-package-contract.d.ts +6 -0
- package/dist/release/public-package-contract.d.ts.map +1 -1
- package/dist/release/public-package-contract.js +75 -0
- package/package.json +2 -2
|
@@ -34,12 +34,14 @@ const TOKEN_PATTERN = /{{([A-Z][A-Z_]*)}}/g;
|
|
|
34
34
|
export async function renderArtifact({
|
|
35
35
|
recipeArtifact,
|
|
36
36
|
content,
|
|
37
|
+
factBase,
|
|
37
38
|
theme,
|
|
38
39
|
renderStrategy,
|
|
39
40
|
publicBaseUrl,
|
|
40
41
|
}) {
|
|
41
42
|
assertRecipeArtifact(recipeArtifact);
|
|
42
43
|
assertContent(content, recipeArtifact);
|
|
44
|
+
assertFactBase(factBase);
|
|
43
45
|
assertTheme(theme);
|
|
44
46
|
assertRenderStrategy(renderStrategy);
|
|
45
47
|
|
|
@@ -52,6 +54,7 @@ export async function renderArtifact({
|
|
|
52
54
|
const sections = content.sections.map((section) => ({
|
|
53
55
|
...section,
|
|
54
56
|
anchor: section.id,
|
|
57
|
+
backlinks: sourceBacklinksForSection(factBase, section.id),
|
|
55
58
|
...prepareSectionMarkdown(section.content, theme),
|
|
56
59
|
}));
|
|
57
60
|
const links = content.artifactLinks ?? [];
|
|
@@ -109,7 +112,14 @@ export function substituteTemplate(template, values) {
|
|
|
109
112
|
throw new Error(`Unresolved template token: ${missing[0]}.`);
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
const
|
|
115
|
+
const normalizedTemplate = normalizeEmptySubstitutionWhitespace(
|
|
116
|
+
template,
|
|
117
|
+
values,
|
|
118
|
+
);
|
|
119
|
+
const rendered = normalizedTemplate.replace(
|
|
120
|
+
TOKEN_PATTERN,
|
|
121
|
+
(_, key) => values[key],
|
|
122
|
+
);
|
|
113
123
|
const unresolved = rendered.match(TOKEN_PATTERN)?.[1];
|
|
114
124
|
if (unresolved) {
|
|
115
125
|
throw new Error(`Unresolved template token: ${unresolved}.`);
|
|
@@ -117,6 +127,19 @@ export function substituteTemplate(template, values) {
|
|
|
117
127
|
return rendered;
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
function normalizeEmptySubstitutionWhitespace(template, values) {
|
|
131
|
+
let normalized = template;
|
|
132
|
+
let previous;
|
|
133
|
+
do {
|
|
134
|
+
previous = normalized;
|
|
135
|
+
normalized = normalized.replace(
|
|
136
|
+
/[ \t]*{{([A-Z][A-Z_]*)}}[ \t]*(?=\r?$)/gm,
|
|
137
|
+
(match, key) => (values[key] === '' ? '' : match),
|
|
138
|
+
);
|
|
139
|
+
} while (normalized !== previous);
|
|
140
|
+
return normalized;
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
function templateValues({
|
|
121
144
|
recipeArtifact,
|
|
122
145
|
content,
|
|
@@ -193,6 +216,7 @@ function templateValues({
|
|
|
193
216
|
`<span>${escapeHtml(section.title ?? humanize(section.id))}</span>`,
|
|
194
217
|
)
|
|
195
218
|
.join(''),
|
|
219
|
+
renderSourceBacklinks(sections.flatMap(({ backlinks }) => backlinks)),
|
|
196
220
|
renderRelatedLinks(links, content.slug, renderedPath, baseUrl),
|
|
197
221
|
].join(''),
|
|
198
222
|
};
|
|
@@ -217,7 +241,7 @@ function renderSections(sections, { tour = false } = {}) {
|
|
|
217
241
|
const tourAttributes = tour
|
|
218
242
|
? ` data-active-nodes="node-${index + 1}" data-active-edges=""`
|
|
219
243
|
: '';
|
|
220
|
-
return `<section id="${escapeAttribute(section.anchor)}"${tourAttributes}><h2>${escapeHtml(section.title ?? humanize(section.id))}</h2>${renderMarkdownNodes(section.ast.children)}</section>`;
|
|
244
|
+
return `<section id="${escapeAttribute(section.anchor)}"${tourAttributes}><h2>${escapeHtml(section.title ?? humanize(section.id))}</h2>${renderMarkdownNodes(section.ast.children)}${renderSourceBacklinks(section.backlinks)}</section>`;
|
|
221
245
|
})
|
|
222
246
|
.join('');
|
|
223
247
|
}
|
|
@@ -349,7 +373,7 @@ function renderSlides(sections, links, slug, renderedPath, baseUrl) {
|
|
|
349
373
|
const slides = sections
|
|
350
374
|
.map(
|
|
351
375
|
(section) =>
|
|
352
|
-
`<section class="slide" id="${escapeAttribute(section.anchor)}"><div class="slide__content"><h2>${escapeHtml(section.title ?? humanize(section.id))}</h2><p>${escapeHtml(section.content)}</p
|
|
376
|
+
`<section class="slide" id="${escapeAttribute(section.anchor)}"><div class="slide__content"><h2>${escapeHtml(section.title ?? humanize(section.id))}</h2><p>${escapeHtml(section.content)}</p>${renderSourceBacklinks(section.backlinks)}</div></section>`,
|
|
353
377
|
)
|
|
354
378
|
.join('');
|
|
355
379
|
const related = renderRelatedLinks(links, slug, renderedPath, baseUrl);
|
|
@@ -387,6 +411,38 @@ function renderRelatedLinks(links, slug, renderedPath, baseUrl) {
|
|
|
387
411
|
.join(' ');
|
|
388
412
|
}
|
|
389
413
|
|
|
414
|
+
function sourceBacklinksForSection(factBase, sectionId) {
|
|
415
|
+
if (factBase === undefined) return [];
|
|
416
|
+
return [...factBase.claims, ...factBase.unresolvedClaims]
|
|
417
|
+
.filter(
|
|
418
|
+
(claim) =>
|
|
419
|
+
claim.sections === undefined || claim.sections.includes(sectionId),
|
|
420
|
+
)
|
|
421
|
+
.flatMap((claim) =>
|
|
422
|
+
claim.citations
|
|
423
|
+
.filter(({ url }) => typeof url === 'string')
|
|
424
|
+
.map((citation) => ({
|
|
425
|
+
claimId: claim.id,
|
|
426
|
+
claimText: claim.text,
|
|
427
|
+
repository: citation.repository,
|
|
428
|
+
path: citation.path,
|
|
429
|
+
lineRange: citation.lineRange,
|
|
430
|
+
url: citation.url,
|
|
431
|
+
})),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function renderSourceBacklinks(backlinks) {
|
|
436
|
+
if (backlinks.length === 0) return '';
|
|
437
|
+
const items = backlinks
|
|
438
|
+
.map(
|
|
439
|
+
(backlink) =>
|
|
440
|
+
`<li><span>${escapeHtml(backlink.claimText)}</span> <a href="${escapeAttribute(backlink.url)}">${escapeHtml(`${backlink.repository}/${backlink.path}:L${backlink.lineRange.start}${backlink.lineRange.end === backlink.lineRange.start ? '' : `-L${backlink.lineRange.end}`}`)}</a></li>`,
|
|
441
|
+
)
|
|
442
|
+
.join('');
|
|
443
|
+
return `<aside class="source-backlinks" aria-label="Sources"><h3>Sources</h3><ul>${items}</ul></aside>`;
|
|
444
|
+
}
|
|
445
|
+
|
|
390
446
|
export function artifactPath(artifact, slug) {
|
|
391
447
|
const directory = TYPE_DIRECTORIES.get(artifact.type);
|
|
392
448
|
if (!directory)
|
|
@@ -559,6 +615,14 @@ function assertContent(content, recipeArtifact) {
|
|
|
559
615
|
}
|
|
560
616
|
}
|
|
561
617
|
|
|
618
|
+
function assertFactBase(factBase) {
|
|
619
|
+
if (factBase === undefined) return;
|
|
620
|
+
const validation = validateContract('fact-base', factBase);
|
|
621
|
+
if (!validation.valid) {
|
|
622
|
+
throw new TypeError('Renderer requires a validated fact base.');
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
562
626
|
function assertTheme(theme) {
|
|
563
627
|
const validation = validateContract('theme', theme);
|
|
564
628
|
const identity = isObject(theme) ? structuredClone(theme) : {};
|
|
@@ -12,6 +12,12 @@ import { tmpdir } from 'node:os';
|
|
|
12
12
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
13
13
|
import { promisify } from 'node:util';
|
|
14
14
|
|
|
15
|
+
import {
|
|
16
|
+
catalogFromManifest,
|
|
17
|
+
initiativeCatalogPath,
|
|
18
|
+
serializeInitiativeCatalog,
|
|
19
|
+
validateInitiativeCatalog,
|
|
20
|
+
} from './catalog.mjs';
|
|
15
21
|
import { validateContract } from './contracts.mjs';
|
|
16
22
|
|
|
17
23
|
const execFile = promisify(execFileCallback);
|
|
@@ -131,15 +137,47 @@ export async function publishS3Static(request, dependencies = {}) {
|
|
|
131
137
|
randomBytes,
|
|
132
138
|
);
|
|
133
139
|
const sentinelTargetPath = targetPath(sentinelRelativePath, roots);
|
|
140
|
+
const catalog = catalogFromManifest(manifest, roots.publicBaseUrl);
|
|
141
|
+
const catalogValidation = validateInitiativeCatalog(
|
|
142
|
+
catalog,
|
|
143
|
+
manifest,
|
|
144
|
+
roots.publicBaseUrl,
|
|
145
|
+
);
|
|
146
|
+
if (!catalogValidation.valid) {
|
|
147
|
+
throw publishError(
|
|
148
|
+
'E_PUBLISH_INPUT',
|
|
149
|
+
`Invalid initiative catalog: ${catalogValidation.errors[0].message}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const catalogBody = Buffer.from(serializeInitiativeCatalog(catalog));
|
|
153
|
+
const catalogManifestPath = initiativeCatalogPath(manifest.slug);
|
|
154
|
+
const catalogPublishPath = catalogManifestPath.slice('site/'.length);
|
|
155
|
+
if (artifacts.some(({ publishPath }) => publishPath === catalogPublishPath)) {
|
|
156
|
+
throw publishError(
|
|
157
|
+
'E_PUBLISH_INPUT',
|
|
158
|
+
`Generated catalog path collides with a manifest artifact: ${catalogManifestPath}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
134
161
|
const sentinelDirectory = await mkdtemp(
|
|
135
162
|
join(tmpdir(), 'explainer-sentinel-'),
|
|
136
163
|
);
|
|
137
164
|
const sentinelBodyPath = join(sentinelDirectory, 'sentinel.txt');
|
|
165
|
+
const catalogBodyPath = join(sentinelDirectory, 'catalog.json');
|
|
166
|
+
const catalogArtifact = {
|
|
167
|
+
manifestPath: catalogManifestPath,
|
|
168
|
+
publishPath: catalogPublishPath,
|
|
169
|
+
filePath: catalogBodyPath,
|
|
170
|
+
hash: fileHash(catalogBody),
|
|
171
|
+
contentType: 'application/json',
|
|
172
|
+
cacheControl: 'public, max-age=300',
|
|
173
|
+
};
|
|
174
|
+
const publicationArtifacts = [...artifacts, catalogArtifact];
|
|
138
175
|
let sentinelUploaded = false;
|
|
139
176
|
let sentinelDeleted = false;
|
|
140
177
|
|
|
141
178
|
try {
|
|
142
179
|
await writeFile(sentinelBodyPath, SENTINEL_BODY, 'utf8');
|
|
180
|
+
await writeFile(catalogBodyPath, catalogBody);
|
|
143
181
|
await runAws(
|
|
144
182
|
command,
|
|
145
183
|
putObjectArgs({
|
|
@@ -178,7 +216,7 @@ export async function publishS3Static(request, dependencies = {}) {
|
|
|
178
216
|
sentinelDeleted = true;
|
|
179
217
|
|
|
180
218
|
const receiptArtifacts = [];
|
|
181
|
-
for (const artifact of
|
|
219
|
+
for (const artifact of publicationArtifacts) {
|
|
182
220
|
const target = targetPath(artifact.publishPath, roots);
|
|
183
221
|
const metadata = await readExistingMetadata(
|
|
184
222
|
command,
|
|
@@ -251,6 +289,10 @@ export async function publishS3Static(request, dependencies = {}) {
|
|
|
251
289
|
};
|
|
252
290
|
const receiptValidation = validateContract('publish-receipt', receipt, {
|
|
253
291
|
manifest,
|
|
292
|
+
catalogArtifact: {
|
|
293
|
+
relativePath: catalogArtifact.manifestPath,
|
|
294
|
+
hash: catalogArtifact.hash,
|
|
295
|
+
},
|
|
254
296
|
});
|
|
255
297
|
if (!receiptValidation.valid) {
|
|
256
298
|
throw publishError(
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { canonicalHash, validateContract } from './contracts.mjs';
|
|
2
|
+
import { recipeExpansion, recipeFloor } from './recipes.mjs';
|
|
3
|
+
|
|
4
|
+
export async function planExplainerSet({
|
|
5
|
+
recipe,
|
|
6
|
+
factBase,
|
|
7
|
+
discovery,
|
|
8
|
+
planSet,
|
|
9
|
+
}) {
|
|
10
|
+
const sourceIds = factBase.sources
|
|
11
|
+
.map(({ id }) => id)
|
|
12
|
+
.filter((id) => !id.startsWith('critic:'));
|
|
13
|
+
const request = deepFreeze({
|
|
14
|
+
schemaVersion: 'explainer-kit.set-plan-request/v1',
|
|
15
|
+
recipe: structuredClone(recipe),
|
|
16
|
+
factBase: structuredClone(factBase),
|
|
17
|
+
discovery: structuredClone(discovery),
|
|
18
|
+
});
|
|
19
|
+
const callback =
|
|
20
|
+
typeof planSet === 'function' ? planSet : createBaselineSetPlan;
|
|
21
|
+
const candidate = await callback(structuredClone(request));
|
|
22
|
+
const validation = validateContract('set-plan', candidate);
|
|
23
|
+
if (!validation.valid) {
|
|
24
|
+
throw setPlanError(
|
|
25
|
+
validation.errors
|
|
26
|
+
.map(({ path, code, message }) => `${path} [${code}]: ${message}`)
|
|
27
|
+
.join('; '),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
validateAgainstInputs(candidate, { recipe, sourceIds });
|
|
31
|
+
|
|
32
|
+
const plan = deepFreeze(structuredClone(candidate));
|
|
33
|
+
const retainedRequest = deepFreeze({
|
|
34
|
+
schemaVersion: request.schemaVersion,
|
|
35
|
+
recipe: { id: recipe.id, version: recipe.version },
|
|
36
|
+
factBaseHash: canonicalHash(factBase),
|
|
37
|
+
sourceIds,
|
|
38
|
+
discovery: structuredClone(discovery),
|
|
39
|
+
});
|
|
40
|
+
return { request: retainedRequest, plan };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function plannedArtifacts(recipe, plan) {
|
|
44
|
+
const floor = new Map(
|
|
45
|
+
recipeFloor(recipe).map((artifact) => [artifact.id, artifact]),
|
|
46
|
+
);
|
|
47
|
+
const profiles = new Map(
|
|
48
|
+
recipeExpansion(recipe).profiles.map((profile) => [
|
|
49
|
+
profile.profileId,
|
|
50
|
+
profile,
|
|
51
|
+
]),
|
|
52
|
+
);
|
|
53
|
+
return plan.portfolio.map((planned) => {
|
|
54
|
+
const floorArtifact = floor.get(planned.artifactId);
|
|
55
|
+
if (floorArtifact) {
|
|
56
|
+
return {
|
|
57
|
+
...floorArtifact,
|
|
58
|
+
origin: 'floor',
|
|
59
|
+
shell:
|
|
60
|
+
floorArtifact.authoring === 'html'
|
|
61
|
+
? floorArtifact.template
|
|
62
|
+
: undefined,
|
|
63
|
+
plannedArtifact: planned,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const profile = profiles.get(planned.profileId);
|
|
67
|
+
return {
|
|
68
|
+
id: planned.artifactId,
|
|
69
|
+
type: profile.type,
|
|
70
|
+
authoring: profile.authoring,
|
|
71
|
+
briefRef: profile.briefRef,
|
|
72
|
+
shell: profile.shell,
|
|
73
|
+
template:
|
|
74
|
+
profile.authoring === 'markdown'
|
|
75
|
+
? templateForType(profile.type)
|
|
76
|
+
: profile.shell,
|
|
77
|
+
required: false,
|
|
78
|
+
origin: 'expansion',
|
|
79
|
+
profileId: profile.profileId,
|
|
80
|
+
plannedArtifact: planned,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createBaselineSetPlan({ recipe, factBase }) {
|
|
86
|
+
const sourceIds = factBase.sources
|
|
87
|
+
.map(({ id }) => id)
|
|
88
|
+
.filter((id) => !id.startsWith('critic:'));
|
|
89
|
+
return {
|
|
90
|
+
schemaVersion: 'explainer-kit.set-plan/v1',
|
|
91
|
+
planId: `${recipe.id}-set`,
|
|
92
|
+
recipe: { id: recipe.id, version: recipe.version },
|
|
93
|
+
sourceIds,
|
|
94
|
+
ledger: { terminology: [], statuses: [], numbers: [] },
|
|
95
|
+
portfolio: recipeFloor(recipe).map((artifact) => ({
|
|
96
|
+
artifactId: artifact.id,
|
|
97
|
+
artifactType: artifact.type,
|
|
98
|
+
profileId: 'recipe-floor',
|
|
99
|
+
required: true,
|
|
100
|
+
sourceIds,
|
|
101
|
+
draft: `Compose ${artifact.id} from the reconciled fact base.`,
|
|
102
|
+
visualIntent: 'Follow the bundled brief and selected medium.',
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateAgainstInputs(plan, { recipe, sourceIds }) {
|
|
108
|
+
if (plan.recipe.id !== recipe.id || plan.recipe.version !== recipe.version) {
|
|
109
|
+
throw setPlanError('Set plan recipe does not match the active recipe.');
|
|
110
|
+
}
|
|
111
|
+
const availableSources = new Set(sourceIds);
|
|
112
|
+
for (const sourceId of plan.sourceIds) {
|
|
113
|
+
if (!availableSources.has(sourceId)) {
|
|
114
|
+
throw setPlanError(
|
|
115
|
+
`Set plan references unknown reconciled source ${sourceId}.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const plannedSources = new Set(plan.sourceIds);
|
|
120
|
+
const omittedSources = sourceIds.filter(
|
|
121
|
+
(sourceId) => !plannedSources.has(sourceId),
|
|
122
|
+
);
|
|
123
|
+
if (
|
|
124
|
+
omittedSources.length > 0 ||
|
|
125
|
+
plannedSources.size !== availableSources.size
|
|
126
|
+
) {
|
|
127
|
+
throw setPlanError(
|
|
128
|
+
`Set plan must declare every reconciled non-critic source; omitted ${omittedSources.join(', ') || 'none'}.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const coveredSources = new Set(
|
|
132
|
+
plan.portfolio.flatMap(({ sourceIds: artifactSourceIds }) =>
|
|
133
|
+
Array.isArray(artifactSourceIds) ? artifactSourceIds : [],
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
const uncoveredSources = plan.sourceIds.filter(
|
|
137
|
+
(sourceId) => !coveredSources.has(sourceId),
|
|
138
|
+
);
|
|
139
|
+
if (uncoveredSources.length > 0) {
|
|
140
|
+
throw setPlanError(
|
|
141
|
+
`Every declared source must cover at least one portfolio artifact; uncovered ${uncoveredSources.join(', ')}.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const plannedById = new Map(
|
|
146
|
+
plan.portfolio.map((artifact) => [artifact.artifactId, artifact]),
|
|
147
|
+
);
|
|
148
|
+
for (const floorArtifact of recipeFloor(recipe)) {
|
|
149
|
+
const planned = plannedById.get(floorArtifact.id);
|
|
150
|
+
if (
|
|
151
|
+
!planned ||
|
|
152
|
+
planned.required !== true ||
|
|
153
|
+
planned.artifactType !== floorArtifact.type
|
|
154
|
+
) {
|
|
155
|
+
throw setPlanError(
|
|
156
|
+
`Set plan is missing required draft ${floorArtifact.id}.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const floorIds = new Set(recipeFloor(recipe).map(({ id }) => id));
|
|
162
|
+
const profiles = new Map(
|
|
163
|
+
recipeExpansion(recipe).profiles.map((profile) => [
|
|
164
|
+
profile.profileId,
|
|
165
|
+
profile,
|
|
166
|
+
]),
|
|
167
|
+
);
|
|
168
|
+
for (const artifact of plan.portfolio) {
|
|
169
|
+
if (floorIds.has(artifact.artifactId)) continue;
|
|
170
|
+
const profile = profiles.get(artifact.profileId);
|
|
171
|
+
if (
|
|
172
|
+
!profile ||
|
|
173
|
+
artifact.required !== false ||
|
|
174
|
+
artifact.artifactType !== profile.type
|
|
175
|
+
) {
|
|
176
|
+
throw setPlanError(
|
|
177
|
+
`Optional artifact ${artifact.artifactId} does not match an allowed recipe profile.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function templateForType(type) {
|
|
184
|
+
return (
|
|
185
|
+
{
|
|
186
|
+
hub: 'house-style',
|
|
187
|
+
diagram: 'diagram-shell',
|
|
188
|
+
explainer: 'engineer-tour',
|
|
189
|
+
deck: 'deck-shell',
|
|
190
|
+
}[type] ?? 'house-style'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function deepFreeze(value) {
|
|
195
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
196
|
+
Object.freeze(value);
|
|
197
|
+
for (const child of Object.values(value)) {
|
|
198
|
+
deepFreeze(child);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function setPlanError(message) {
|
|
205
|
+
const error = new Error(`Invalid set plan: ${message}`);
|
|
206
|
+
error.code = 'E_SET_PLAN';
|
|
207
|
+
return error;
|
|
208
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
export const SOURCE_BACKLINK_CONTRACT_VERSION =
|
|
2
|
+
'explainer-kit.source-backlinks/v1';
|
|
3
|
+
|
|
4
|
+
const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
5
|
+
const REVISION_PATTERN = /^[a-f0-9]{40}$/;
|
|
6
|
+
const LINE_FRAGMENT_PATTERN = /^L([1-9][0-9]*)(?:-L([1-9][0-9]*))?$/;
|
|
7
|
+
|
|
8
|
+
export function canonicalGithubBlobBacklink(tuple) {
|
|
9
|
+
const normalized = normalizeBacklinkTuple(tuple);
|
|
10
|
+
const encodedPath = normalized.path
|
|
11
|
+
.split('/')
|
|
12
|
+
.map((segment) => encodeURIComponent(segment))
|
|
13
|
+
.join('/');
|
|
14
|
+
const fragment =
|
|
15
|
+
normalized.lineRange.start === normalized.lineRange.end
|
|
16
|
+
? `L${normalized.lineRange.start}`
|
|
17
|
+
: `L${normalized.lineRange.start}-L${normalized.lineRange.end}`;
|
|
18
|
+
return `https://github.com/${normalized.repository}/blob/${normalized.revision}/${encodedPath}#${fragment}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateCanonicalGithubBlobTuple(tuple) {
|
|
22
|
+
try {
|
|
23
|
+
const normalized = normalizeBacklinkTuple(tuple);
|
|
24
|
+
if (
|
|
25
|
+
typeof tuple?.url !== 'string' ||
|
|
26
|
+
tuple.url !== canonicalGithubBlobBacklink(normalized)
|
|
27
|
+
) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return sameTuple(parseCanonicalGithubBlobUrl(tuple.url), {
|
|
31
|
+
...normalized,
|
|
32
|
+
url: tuple.url,
|
|
33
|
+
});
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseCanonicalGithubBlobUrl(value) {
|
|
40
|
+
if (typeof value !== 'string') {
|
|
41
|
+
throw backlinkError('GitHub backlink URL must be a string.');
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
!value.startsWith('https://github.com/') ||
|
|
45
|
+
value.includes('?') ||
|
|
46
|
+
value.includes('\\')
|
|
47
|
+
) {
|
|
48
|
+
throw backlinkError(
|
|
49
|
+
'GitHub backlink must use canonical credential-free HTTPS without query.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const hashIndex = value.indexOf('#');
|
|
53
|
+
if (hashIndex < 0 || value.indexOf('#', hashIndex + 1) >= 0) {
|
|
54
|
+
throw backlinkError(
|
|
55
|
+
'GitHub backlink requires one canonical line fragment.',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const rawPath = value.slice('https://github.com/'.length, hashIndex);
|
|
59
|
+
const fragment = value.slice(hashIndex + 1);
|
|
60
|
+
const rawSegments = rawPath.split('/');
|
|
61
|
+
if (
|
|
62
|
+
rawSegments.length < 5 ||
|
|
63
|
+
rawSegments.some((segment) => segment.length === 0)
|
|
64
|
+
) {
|
|
65
|
+
throw backlinkError('GitHub backlink path contains an empty segment.');
|
|
66
|
+
}
|
|
67
|
+
const [owner, repositoryName, blob, revision, ...rawFileSegments] =
|
|
68
|
+
rawSegments;
|
|
69
|
+
if (
|
|
70
|
+
blob !== 'blob' ||
|
|
71
|
+
!REPOSITORY_PATTERN.test(`${owner}/${repositoryName}`) ||
|
|
72
|
+
!REVISION_PATTERN.test(revision)
|
|
73
|
+
) {
|
|
74
|
+
throw backlinkError(
|
|
75
|
+
'GitHub backlink requires a canonical repository and full commit SHA.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const fileSegments = rawFileSegments.map(decodeCanonicalSegment);
|
|
79
|
+
const lineMatch = fragment.match(LINE_FRAGMENT_PATTERN);
|
|
80
|
+
if (!lineMatch) {
|
|
81
|
+
throw backlinkError('GitHub backlink requires a canonical line range.');
|
|
82
|
+
}
|
|
83
|
+
const start = Number(lineMatch[1]);
|
|
84
|
+
const end = Number(lineMatch[2] ?? lineMatch[1]);
|
|
85
|
+
if (end < start) {
|
|
86
|
+
throw backlinkError('GitHub backlink line range is reversed.');
|
|
87
|
+
}
|
|
88
|
+
const tuple = {
|
|
89
|
+
repository: `${owner}/${repositoryName}`,
|
|
90
|
+
revision,
|
|
91
|
+
path: fileSegments.join('/'),
|
|
92
|
+
lineRange: { start, end },
|
|
93
|
+
};
|
|
94
|
+
const canonical = canonicalGithubBlobBacklink(tuple);
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = new URL(value);
|
|
98
|
+
} catch {
|
|
99
|
+
throw backlinkError('GitHub backlink URL is malformed.');
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
parsed.protocol !== 'https:' ||
|
|
103
|
+
parsed.hostname !== 'github.com' ||
|
|
104
|
+
parsed.port ||
|
|
105
|
+
parsed.username ||
|
|
106
|
+
parsed.password ||
|
|
107
|
+
parsed.search ||
|
|
108
|
+
parsed.href !== canonical ||
|
|
109
|
+
value !== canonical
|
|
110
|
+
) {
|
|
111
|
+
throw backlinkError('GitHub backlink URL is not canonical.');
|
|
112
|
+
}
|
|
113
|
+
return { ...tuple, url: canonical };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeBacklinkTuple(tuple) {
|
|
117
|
+
if (
|
|
118
|
+
!tuple ||
|
|
119
|
+
typeof tuple !== 'object' ||
|
|
120
|
+
!REPOSITORY_PATTERN.test(tuple.repository ?? '') ||
|
|
121
|
+
!REVISION_PATTERN.test(tuple.revision ?? '')
|
|
122
|
+
) {
|
|
123
|
+
throw backlinkError(
|
|
124
|
+
'GitHub backlink tuple requires a canonical repository and full commit SHA.',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const path = normalizeRepositoryPath(tuple.path);
|
|
128
|
+
const lineRange = normalizeLineRange(tuple.lineRange);
|
|
129
|
+
return {
|
|
130
|
+
repository: tuple.repository,
|
|
131
|
+
revision: tuple.revision,
|
|
132
|
+
path,
|
|
133
|
+
lineRange,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeRepositoryPath(value) {
|
|
138
|
+
if (
|
|
139
|
+
typeof value !== 'string' ||
|
|
140
|
+
value.length === 0 ||
|
|
141
|
+
value.startsWith('/') ||
|
|
142
|
+
value.includes('\\')
|
|
143
|
+
) {
|
|
144
|
+
throw backlinkError(
|
|
145
|
+
'GitHub backlink tuple requires a repository-relative path.',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const segments = value.split('/');
|
|
149
|
+
if (
|
|
150
|
+
segments.some(
|
|
151
|
+
(segment) =>
|
|
152
|
+
segment.length === 0 ||
|
|
153
|
+
segment === '.' ||
|
|
154
|
+
segment === '..' ||
|
|
155
|
+
segment.includes('/') ||
|
|
156
|
+
segment.includes('\\') ||
|
|
157
|
+
segment.includes('\0'),
|
|
158
|
+
)
|
|
159
|
+
) {
|
|
160
|
+
throw backlinkError('GitHub backlink path contains an unsafe segment.');
|
|
161
|
+
}
|
|
162
|
+
return segments.join('/');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeLineRange(value) {
|
|
166
|
+
if (
|
|
167
|
+
!value ||
|
|
168
|
+
!Number.isInteger(value.start) ||
|
|
169
|
+
!Number.isInteger(value.end) ||
|
|
170
|
+
value.start < 1 ||
|
|
171
|
+
value.end < value.start
|
|
172
|
+
) {
|
|
173
|
+
throw backlinkError(
|
|
174
|
+
'GitHub backlink tuple requires a valid inclusive line range.',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return { start: value.start, end: value.end };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function decodeCanonicalSegment(rawSegment) {
|
|
181
|
+
let decoded;
|
|
182
|
+
try {
|
|
183
|
+
decoded = decodeURIComponent(rawSegment);
|
|
184
|
+
} catch {
|
|
185
|
+
throw backlinkError('GitHub backlink path has malformed encoding.');
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
decoded.length === 0 ||
|
|
189
|
+
decoded === '.' ||
|
|
190
|
+
decoded === '..' ||
|
|
191
|
+
decoded.includes('/') ||
|
|
192
|
+
decoded.includes('\\') ||
|
|
193
|
+
decoded.includes('\0') ||
|
|
194
|
+
encodeURIComponent(decoded) !== rawSegment
|
|
195
|
+
) {
|
|
196
|
+
throw backlinkError(
|
|
197
|
+
'GitHub backlink path has an unsafe or noncanonical encoded segment.',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return decoded;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sameTuple(left, right) {
|
|
204
|
+
return (
|
|
205
|
+
left.repository === right.repository &&
|
|
206
|
+
left.revision === right.revision &&
|
|
207
|
+
left.path === right.path &&
|
|
208
|
+
left.lineRange.start === right.lineRange.start &&
|
|
209
|
+
left.lineRange.end === right.lineRange.end &&
|
|
210
|
+
left.url === right.url
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function backlinkError(message) {
|
|
215
|
+
const error = new TypeError(message);
|
|
216
|
+
error.code = 'E_SOURCE_BACKLINK';
|
|
217
|
+
return error;
|
|
218
|
+
}
|