-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workspace-primitives): add digest() and layoutManifest() exports across first-party adapters #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(workspace-primitives): add digest() and layoutManifest() exports across first-party adapters #93
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import test from 'node:test'; | ||
|
|
||
| import { digest, type DigestContext } from './digest.js'; | ||
|
|
||
| test('digest returns null for Confluence M1 no-op activity windows', async () => { | ||
| const ctx: DigestContext = { | ||
| provider: 'confluence', | ||
| window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, | ||
| async changeEvents() { | ||
| return [ | ||
| { | ||
| id: 'evt-1', | ||
| timestamp: '2026-05-12T08:00:00.000Z', | ||
| canonicalPath: '/confluence/pages/123__release-plan.json', | ||
| }, | ||
| ]; | ||
| }, | ||
| }; | ||
|
|
||
| // Determinism: two runs over the same fixture produce byte-identical | ||
| // results (WI 2 acceptance criterion 4 in the workspace-primitives spec). | ||
| const first = await digest(ctx); | ||
| const second = await digest(ctx); | ||
| assert.deepEqual(second, first); | ||
| assert.equal(first, null); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| export interface DigestContext { | ||
| readonly provider: string; | ||
| readonly window: { | ||
| readonly from: string; | ||
| readonly to: string; | ||
| }; | ||
| changeEvents(filter?: { | ||
| providers?: string[]; | ||
| paths?: string[]; | ||
| }): Promise<readonly unknown[]>; | ||
| } | ||
|
|
||
| export interface DigestBullet { | ||
| readonly text: string; | ||
| readonly canonicalPath: string; | ||
| } | ||
|
|
||
| export interface DigestSection { | ||
| readonly provider: string; | ||
| readonly bullets: readonly DigestBullet[]; | ||
| } | ||
|
|
||
| export type DigestHandler = (ctx: DigestContext) => Promise<DigestSection | null>; | ||
|
|
||
| export const digest: DigestHandler = async () => null; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import test from 'node:test'; | ||
|
|
||
| import { layoutManifest } from './layout.js'; | ||
|
|
||
| const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']); | ||
|
|
||
| test('layoutManifest exposes Confluence resources with canonical aliases and writeback schema pointers', () => { | ||
| const manifest = layoutManifest(); | ||
|
|
||
| assert.equal(manifest.provider, 'confluence'); | ||
| assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-title', 'by-state']); | ||
|
|
||
| for (const resource of manifest.resources) { | ||
| assert.ok(resource.path.startsWith('confluence/')); | ||
| assert.doesNotMatch(resource.path, /^\//u); | ||
| for (const alias of resource.aliasSegments) { | ||
| assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`); | ||
| } | ||
| for (const writeback of resource.writebackResources) { | ||
| assert.ok(writeback.path.startsWith('confluence/')); | ||
| assert.doesNotMatch(writeback.path, /^\//u); | ||
| assert.match(writeback.schemaId, /^confluence\/[a-z-]+$/u); | ||
| } | ||
| } | ||
| }); | ||
|
Comment on lines
+8
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add collision coverage for alias subtrees declared by the manifest. This suite validates allowed alias segment names but does not verify collision behavior (e.g., two inputs normalizing to the same alias key) for the declared alias subtrees. Please add at least one collision test per subtree to meet the repository test contract. As per coding guidelines 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| export type MaterializationMode = 'eager' | 'lazy'; | ||
|
|
||
| export interface WritebackResourceManifest { | ||
| readonly path: string; | ||
| readonly schemaId: string; | ||
| } | ||
|
|
||
| export interface LayoutResourceManifest { | ||
| readonly path: string; | ||
| readonly title: string; | ||
| readonly materialization: MaterializationMode; | ||
| readonly aliasSegments: readonly string[]; | ||
| readonly writebackResources: readonly WritebackResourceManifest[]; | ||
| } | ||
|
|
||
| export interface LayoutManifest { | ||
| readonly provider: string; | ||
| readonly filenameConvention: string; | ||
| readonly aliasSegments: readonly string[]; | ||
| readonly resources: readonly LayoutResourceManifest[]; | ||
| } | ||
|
|
||
| export type LayoutManifestProvider = () => LayoutManifest; | ||
|
|
||
| export const layoutManifest: LayoutManifestProvider = () => ({ | ||
| provider: 'confluence', | ||
| filenameConvention: '<slug>__<id>.json', | ||
| aliasSegments: ['by-id', 'by-title', 'by-state'], | ||
| resources: [ | ||
| { | ||
| path: 'confluence/pages', | ||
| title: 'Pages', | ||
| materialization: 'eager', | ||
| aliasSegments: ['by-id', 'by-title', 'by-state'], | ||
| writebackResources: [ | ||
| { path: 'confluence/pages', schemaId: 'confluence/page' }, | ||
| ], | ||
| }, | ||
| { | ||
| path: 'confluence/spaces', | ||
| title: 'Spaces', | ||
| materialization: 'eager', | ||
| aliasSegments: ['by-id', 'by-title'], | ||
| writebackResources: [], | ||
| }, | ||
| ], | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import test from 'node:test'; | ||
|
|
||
| import { digest, type DigestContext } from './digest.js'; | ||
|
|
||
| test('digest returns deterministic GitHub bullets sorted by event time and id', async () => { | ||
| const ctx: DigestContext = { | ||
| provider: 'github', | ||
| window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, | ||
| async changeEvents(filter) { | ||
| assert.deepEqual(filter, { providers: ['github'] }); | ||
| return [ | ||
| { | ||
| id: 'evt-2', | ||
| timestamp: '2026-05-12T09:00:00.000Z', | ||
| action: 'closed', | ||
| canonicalPath: 'github/repos/acme/api/issues/43__remove-flake/meta.json', | ||
| }, | ||
| { | ||
| id: 'evt-1', | ||
| timestamp: '2026-05-12T08:00:00.000Z', | ||
| action: 'opened', | ||
| canonicalPath: '/github/repos/acme/api/issues/42__add-login/meta.json', | ||
| }, | ||
| ]; | ||
| }, | ||
| }; | ||
|
|
||
| const first = await digest(ctx); | ||
| const second = await digest(ctx); | ||
|
|
||
| assert.deepEqual(first, second); | ||
| assert.deepEqual(first, { | ||
| provider: 'github', | ||
| bullets: [ | ||
| { | ||
| text: '#42 was opened', | ||
| canonicalPath: 'github/repos/acme/api/issues/42__add-login/meta.json', | ||
| }, | ||
| { | ||
| text: '#43 was closed', | ||
| canonicalPath: 'github/repos/acme/api/issues/43__remove-flake/meta.json', | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| test('digest classifies "reopened" as updated, not opened (word-boundary regex)', async () => { | ||
| const ctx: DigestContext = { | ||
| provider: 'github', | ||
| window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, | ||
| async changeEvents() { | ||
| return [ | ||
| { | ||
| id: 'evt-1', | ||
| timestamp: '2026-05-12T08:00:00.000Z', | ||
| action: 'reopened', | ||
| canonicalPath: 'github/repos/acme/api/issues/42__add-login/meta.json', | ||
| }, | ||
| ]; | ||
| }, | ||
| }; | ||
|
|
||
| const result = await digest(ctx); | ||
| assert.deepEqual(result, { | ||
| provider: 'github', | ||
| bullets: [ | ||
| { | ||
| text: '#42 was updated', | ||
| canonicalPath: 'github/repos/acme/api/issues/42__add-login/meta.json', | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| test('digest returns null for an empty GitHub event window', async () => { | ||
| const ctx: DigestContext = { | ||
| provider: 'github', | ||
| window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' }, | ||
| async changeEvents() { | ||
| return []; | ||
| }, | ||
| }; | ||
|
|
||
| assert.equal(await digest(ctx), null); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| export interface DigestWindow { | ||
| readonly from: string; | ||
| readonly to: string; | ||
| } | ||
|
|
||
| export interface DigestChangeEvent { | ||
| readonly id?: string; | ||
| readonly timestamp?: string; | ||
| readonly occurredAt?: string; | ||
| readonly eventType?: string; | ||
| readonly type?: string; | ||
| readonly action?: string; | ||
| readonly canonicalPath?: string; | ||
| readonly path?: string; | ||
| } | ||
|
|
||
| export interface DigestContext { | ||
| readonly provider: string; | ||
| readonly window: DigestWindow; | ||
| changeEvents(filter?: { | ||
| providers?: string[]; | ||
| paths?: string[]; | ||
| }): Promise<readonly DigestChangeEvent[]>; | ||
| } | ||
|
|
||
| export interface DigestBullet { | ||
| readonly text: string; | ||
| readonly canonicalPath: string; | ||
| } | ||
|
|
||
| export interface DigestSection { | ||
| readonly provider: string; | ||
| readonly bullets: readonly DigestBullet[]; | ||
| } | ||
|
|
||
| export type DigestHandler = (ctx: DigestContext) => Promise<DigestSection | null>; | ||
|
|
||
| export const digest: DigestHandler = async (ctx) => { | ||
| const events = await ctx.changeEvents({ providers: [ctx.provider] }); | ||
| const bullets = events | ||
| .filter(hasCanonicalPath) | ||
| .slice() | ||
| .sort(compareEvents) | ||
| .map((event) => { | ||
| const canonicalPath = normalizeDigestPath(event.canonicalPath); | ||
| return { | ||
| text: `${githubIdentifier(canonicalPath)} ${pastTense(event)}`, | ||
| canonicalPath, | ||
| }; | ||
| }); | ||
|
|
||
| return bullets.length === 0 ? null : { provider: ctx.provider, bullets }; | ||
| }; | ||
|
|
||
| function hasCanonicalPath(event: DigestChangeEvent): event is DigestChangeEvent & { canonicalPath: string } { | ||
| return ( | ||
| typeof event.canonicalPath === 'string' | ||
| && (event.canonicalPath === 'github' || event.canonicalPath.startsWith('github/') || event.canonicalPath.startsWith('/github/')) | ||
| ); | ||
| } | ||
|
|
||
| function compareEvents(left: DigestChangeEvent, right: DigestChangeEvent): number { | ||
| // Compare parsed timestamps in ms rather than ISO strings: lexicographic | ||
| // string compare misorders events whose timestamps describe the same | ||
| // instant with different textual offsets (e.g. `Z` vs `+00:00`). | ||
| const leftMs = eventTimeMs(left); | ||
| const rightMs = eventTimeMs(right); | ||
| return ( | ||
| leftMs - rightMs | ||
| || (left.id ?? '').localeCompare(right.id ?? '') | ||
| || (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '') | ||
| ); | ||
| } | ||
|
|
||
| function eventTime(event: DigestChangeEvent): string { | ||
| return event.timestamp ?? event.occurredAt ?? ''; | ||
| } | ||
|
|
||
| function eventTimeMs(event: DigestChangeEvent): number { | ||
| const raw = eventTime(event); | ||
| if (!raw) return Number.NEGATIVE_INFINITY; | ||
| const ms = Date.parse(raw); | ||
| return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms; | ||
| } | ||
|
|
||
| function normalizeDigestPath(path: string): string { | ||
| return path.replace(/^\/+/u, ''); | ||
| } | ||
|
|
||
| function githubIdentifier(path: string): string { | ||
| const segments = path.split('/').filter(Boolean); | ||
| const segment = segments.at(-1) === 'meta.json' || segments.at(-1) === 'metadata.json' | ||
| ? segments.at(-2) ?? path | ||
| : segments.at(-1) ?? path; | ||
| const basename = segment.replace(/\.[^.]+$/u, ''); | ||
| const separatorIndex = basename.lastIndexOf('__'); | ||
| return separatorIndex > 0 ? `#${basename.slice(0, separatorIndex)}` : basename; | ||
| } | ||
|
|
||
| function pastTense(event: DigestChangeEvent): string { | ||
| const action = (event.action ?? event.eventType ?? event.type ?? '').toLowerCase(); | ||
| // Word boundaries (\b) prevent substring matches like "reopened" being | ||
| // misclassified as "opened" — a real GitHub webhook action that would | ||
| // otherwise produce a semantically wrong digest line. | ||
| if (/\b(open|opened|create|created|add|added|write|written)\b/u.test(action)) { | ||
| return 'was opened'; | ||
| } | ||
| if (/\b(close|closed|merge|merged)\b/u.test(action)) { | ||
| return 'was closed'; | ||
| } | ||
| if (/\b(delete|deleted|remove|removed)\b/u.test(action)) { | ||
| return 'was deleted'; | ||
| } | ||
| return 'was updated'; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import test from 'node:test'; | ||
|
|
||
| import { layoutManifest } from './layout.js'; | ||
|
|
||
| const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']); | ||
|
|
||
| test('layoutManifest exposes GitHub resources with canonical aliases and writeback schema pointers', () => { | ||
| const manifest = layoutManifest(); | ||
|
|
||
| assert.equal(manifest.provider, 'github'); | ||
| assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-name', 'by-title']); | ||
| assert.ok(manifest.resources.length > 0); | ||
|
|
||
| for (const resource of manifest.resources) { | ||
| assert.ok(resource.path.startsWith('github/')); | ||
| assert.doesNotMatch(resource.path, /^\//u); | ||
| for (const alias of resource.aliasSegments) { | ||
| assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`); | ||
| } | ||
| for (const writeback of resource.writebackResources) { | ||
| assert.ok(writeback.path.startsWith('github/')); | ||
| assert.doesNotMatch(writeback.path, /^\//u); | ||
| assert.match(writeback.schemaId, /^github\/[a-z-]+$/u); | ||
| } | ||
| } | ||
| }); | ||
|
Comment on lines
+8
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add alias-collision coverage for declared alias subtrees. This test validates allowed alias names, but it does not verify collision behavior for the alias subtrees exposed by the manifest (for example As per coding guidelines, " 🤖 Prompt for AI Agents |
||
|
|
||
| test('layoutManifest top-level aliasSegments contains the union of all resource alias segments', () => { | ||
| const manifest = layoutManifest(); | ||
| const declared = new Set(manifest.aliasSegments); | ||
| for (const resource of manifest.resources) { | ||
| for (const alias of resource.aliasSegments) { | ||
| assert.ok( | ||
| declared.has(alias), | ||
| `top-level aliasSegments missing ${alias} declared by resource ${resource.path}`, | ||
| ); | ||
| } | ||
| } | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.