@open-agent-toolkit/cli 0.2.26 → 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.
Files changed (64) hide show
  1. package/assets/NOTICES.md +156 -0
  2. package/assets/docs/contributing/explainer-kit-verification.md +125 -0
  3. package/assets/docs/contributing/index.md +1 -0
  4. package/assets/docs/reference/troubleshooting.md +47 -0
  5. package/assets/docs/workflows/projects/artifacts.md +24 -6
  6. package/assets/docs/workflows/skills/explainer-kit-providers.md +144 -0
  7. package/assets/docs/workflows/skills/explainer-kit.md +121 -69
  8. package/assets/docs/workflows/skills/index.md +1 -0
  9. package/assets/public-package-versions.json +4 -4
  10. package/assets/skills/explainer-kit/SKILL.md +18 -3
  11. package/assets/skills/explainer-kit/recipes/project-recap.json +43 -16
  12. package/assets/skills/explainer-kit/references/contracts.md +167 -20
  13. package/assets/skills/explainer-kit/references/golden-conformance.md +80 -0
  14. package/assets/skills/explainer-kit/references/visual-authoring.md +92 -0
  15. package/assets/skills/explainer-kit/references/visual-review.md +57 -0
  16. package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +172 -1
  17. package/assets/skills/explainer-kit/schemas/build-record.schema.json +7 -1
  18. package/assets/skills/explainer-kit/schemas/fact-base.schema.json +38 -2
  19. package/assets/skills/explainer-kit/schemas/manifest.schema.json +25 -1
  20. package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -0
  21. package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +149 -0
  22. package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +117 -0
  23. package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +80 -0
  24. package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +148 -4
  25. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +243 -0
  26. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +586 -8
  27. package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +285 -8
  28. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +35 -0
  29. package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +144 -8
  30. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +379 -0
  31. package/assets/skills/explainer-kit/scripts/lib/png.mjs +287 -0
  32. package/assets/skills/explainer-kit/scripts/lib/qa.mjs +280 -8
  33. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +132 -3
  34. package/assets/skills/explainer-kit/scripts/lib/records.mjs +513 -21
  35. package/assets/skills/explainer-kit/scripts/lib/render.mjs +67 -3
  36. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +43 -1
  37. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +208 -0
  38. package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +218 -0
  39. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +380 -0
  40. package/assets/skills/explainer-kit/scripts/render-qa.mjs +48 -10
  41. package/assets/skills/explainer-kit/scripts/run.mjs +859 -134
  42. package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
  43. package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
  44. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
  45. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
  46. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
  47. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
  48. package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
  49. package/dist/commands/project/archive/archive-utils.d.ts +1 -0
  50. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  51. package/dist/commands/project/archive/archive-utils.js +109 -42
  52. package/dist/commands/project/archive/explainer-package-coverage.d.ts +14 -0
  53. package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -0
  54. package/dist/commands/project/archive/explainer-package-coverage.js +27 -0
  55. package/dist/commands/project/archive/explainer-source-backlinks.d.ts +18 -0
  56. package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +1 -0
  57. package/dist/commands/project/archive/explainer-source-backlinks.js +27 -0
  58. package/dist/commands/project/archive/push-runner.d.ts +2 -1
  59. package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
  60. package/dist/commands/project/archive/push-runner.js +5 -1
  61. package/dist/release/public-package-contract.d.ts +6 -0
  62. package/dist/release/public-package-contract.d.ts.map +1 -1
  63. package/dist/release/public-package-contract.js +75 -0
  64. 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 rendered = template.replace(TOKEN_PATTERN, (_, key) => values[key]);
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></div></section>`,
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 artifacts) {
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
+ }