Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ bun run scripts/docs.ts boot 3.5.16
# Require a cache hit (offline), or force a re-download
bun run scripts/docs.ts boot 3.5.16 --no-fetch
bun run scripts/docs.ts boot 3.5.16 --refresh

# Which projects and versions are published at all?
bun run scripts/docs.ts --list
bun run scripts/docs.ts --list framework
```

```json
Expand Down Expand Up @@ -133,15 +137,25 @@ Each unpacked tree carries the `manifest.json` from its release: upstream reposi

## Coverage

| Project | Versions | Source |
|---|---|---|
| `boot` | Spring Boot `3.3.0`-`3.x`, `4.0.8`+ | [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) releases |
Two project keys resolve today, both served from [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) releases:

- `boot` — Spring Boot reference
- `framework` — Spring Framework reference

Which versions each key resolves is the catalog's answer, not this file's — the docs repository publishes on its own schedule, and a list written here would be stale the first time it does:

```bash
node skills/spring-docs/scripts/docs.mjs --list # every project
node skills/spring-docs/scripts/docs.mjs --list boot # just one
```

Not buildable upstream, and therefore absent: Boot 3.2 and older predate the Antora documentation component, and 4.0.0-4.0.7 publish no content archive. Pre-release versions (M, RC, SNAPSHOT) are out of scope.

Spring Boot 3.x trees omit the generated appendix — auto-configuration class listings and configuration-property tables are a Gradle build output upstream never publishes. The prose corpus (reference, how-to, tutorial, specification) is complete.

Framework, Security, Data and Cloud are not published yet. When they are, resolving them is the same call with a different project key; BOM-based resolution of one declared Boot version into the whole component matrix belongs to that point, not before it.
Security, Data and Cloud are not published yet. When they are, resolving them is the same call with a different project key.

Version *detection* remains Boot-only, because Boot is the only component a build file declares — Framework arrives as a transitive dependency and appears in no `build.gradle` or `pom.xml`. Resolving one declared Boot version into the whole component matrix through its BOM is the thing that would close that gap, and it is not built yet.

## Plugin structure

Expand Down
75 changes: 75 additions & 0 deletions scripts/__tests__/docs-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isSafeSegment,
lookupTag,
parseChecksum,
summarizeCatalog,
} from '../lib/docs-cache.ts'

const CATALOG = {
Expand Down Expand Up @@ -156,3 +157,77 @@ describe('isCatalog', () => {
expect(isCatalog({ version: '1', projects: { boot: { '3.5.16': { tag: 'boot-3.5.16' } } } })).toBe(false)
})
})

describe('summarizeCatalog', () => {
const MULTI = {
version: '1',
generated_at: '2026-09-15T13:47:09.470Z',
projects: {
framework: {
'6.2.0': { tag: 'framework-6.2.0', released_at: '2026-01-01T00:00:00Z' },
},
boot: {
'3.5.9': { tag: 'boot-3.5.9', released_at: '2026-05-01T00:00:00Z' },
'3.5.10': { tag: 'boot-3.5.10', released_at: '2026-06-01T00:00:00Z' },
'4.1.1': { tag: 'boot-4.1.1', released_at: null },
},
},
}

test('reports every project, with reserved tags kept out of the published list', () => {
expect(summarizeCatalog(MULTI)).toEqual({
kind: 'coverage',
projects: [
{ project: 'boot', published: ['3.5.9', '3.5.10'], unpublished: ['4.1.1'] },
{ project: 'framework', published: ['6.2.0'], unpublished: [] },
],
})
})

test('orders versions numerically, so 3.5.10 follows 3.5.9 instead of preceding it', () => {
const summary = summarizeCatalog(MULTI, 'boot')
expect(summary).toMatchObject({ kind: 'coverage' })
expect(summary.kind === 'coverage' && summary.projects[0]?.published).toEqual(['3.5.9', '3.5.10'])
})

test('narrows to one project when asked', () => {
expect(summarizeCatalog(MULTI, 'framework')).toEqual({
kind: 'coverage',
projects: [{ project: 'framework', published: ['6.2.0'], unpublished: [] }],
})
})

test('names the known projects when the requested one is absent', () => {
expect(summarizeCatalog(MULTI, 'security')).toEqual({
kind: 'unknown-project',
project: 'security',
known: ['boot', 'framework'],
})
})

test('orders a shorter version before the longer one it prefixes', () => {
const catalog = {
...MULTI,
projects: {
boot: {
'4.0': { tag: 'boot-4.0', released_at: '2026-01-01T00:00:00Z' },
'4.0.8': { tag: 'boot-4.0.8', released_at: '2026-01-01T00:00:00Z' },
'4': { tag: 'boot-4', released_at: '2026-01-01T00:00:00Z' },
},
},
}
const summary = summarizeCatalog(catalog, 'boot')
expect(summary.kind === 'coverage' && summary.projects[0]?.published).toEqual(['4', '4.0', '4.0.8'])
})

test('refuses a catalog schema it does not understand, as lookupTag does', () => {
expect(summarizeCatalog({ ...MULTI, version: '2' })).toEqual({ kind: 'schema', found: '2' })
})

test('reports a project that publishes nothing as empty rather than absent', () => {
expect(summarizeCatalog({ ...MULTI, projects: { boot: {} } })).toEqual({
kind: 'coverage',
projects: [{ project: 'boot', published: [], unpublished: [] }],
})
})
})
114 changes: 113 additions & 1 deletion scripts/__tests__/docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { basename, dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'

import { resolveDocs } from '../docs.ts'
import { listDocs, parseArgs, resolveDocs } from '../docs.ts'
import { archiveName, archiveUrl, CATALOG_URL, checksumUrl, DOCS_CACHE_SUBDIR, docsCachePath } from '../lib/docs-cache.ts'

const PROJECT = 'boot'
Expand Down Expand Up @@ -586,3 +586,115 @@
expect(readFileSync(index, 'utf8')).toBe('# First\n')
})
})

describe('listDocs', () => {
const COVERAGE_CATALOG = JSON.stringify({
version: '1',
generated_at: '2026-09-15T13:47:09.470Z',
projects: {
boot: {
'3.5.16': { tag: 'boot-3.5.16', released_at: '2026-09-12T12:36:20Z' },
'4.1.1': { tag: 'boot-4.1.1', released_at: null },
},
framework: { '7.0.9': { tag: 'framework-7.0.9', released_at: '2026-09-15T13:43:47Z' } },
},
})

function serving(body: string, ok = true, status = 200): { fetchImpl: Fetcher, requested: string[] } {
const requested: string[] = []
const fetchImpl: Fetcher = async (url) => {
requested.push(url)
return respond(body, ok, status)
}
return { fetchImpl, requested }
}

test('reports every project the catalog publishes, reading only the catalog', async () => {
const { fetchImpl, requested } = serving(COVERAGE_CATALOG)
const result = await listDocs({ fetchImpl })

expect(result).toEqual({
kind: 'coverage',
generatedAt: '2026-09-15T13:47:09.470Z',
projects: [
{ project: 'boot', published: ['3.5.16'], unpublished: ['4.1.1'] },
{ project: 'framework', published: ['7.0.9'], unpublished: [] },
],
})
// No archive and no checksum: a coverage question must never cost a download.
expect(requested).toEqual([CATALOG_URL])
})

test('narrows to one project', async () => {
const { fetchImpl } = serving(COVERAGE_CATALOG)
const result = await listDocs({ fetchImpl, project: 'framework' })
expect(result).toMatchObject({ kind: 'coverage', projects: [{ project: 'framework' }] })
})

test('names the known projects for one the docs repo does not publish', async () => {
const { fetchImpl } = serving(COVERAGE_CATALOG)
const result = await listDocs({ fetchImpl, project: 'security' })
expect(result).toEqual({
kind: 'unavailable',
reason: 'pleaseai/spring-docs publishes no project "security"',
suggestion: 'known projects: boot, framework',
})
})

test('reports an unreachable catalog rather than throwing', async () => {
const { fetchImpl } = serving('not found', false, 404)
const result = await listDocs({ fetchImpl })
expect(result).toMatchObject({
kind: 'unavailable',
suggestion: 'check network access to raw.githubusercontent.com',
})
})

test('reports a catalog whose shape it does not recognize', async () => {
const { fetchImpl } = serving(JSON.stringify({ version: '1', projects: [] }))
expect(await listDocs({ fetchImpl })).toEqual({
kind: 'unavailable',
reason: 'catalog.json does not have the expected shape',
suggestion: 'update the plugin',
})
})

test('reports a catalog schema newer than this plugin', async () => {
const { fetchImpl } = serving(JSON.stringify({ version: '2', generated_at: null, projects: {} }))
expect(await listDocs({ fetchImpl })).toEqual({
kind: 'unavailable',
reason: 'catalog.json is schema version 2, this plugin understands 1',
suggestion: 'update the plugin',
})
})
})

describe('parseArgs', () => {
test('reads a resolution', () => {
expect(parseArgs(['boot', '4.1.1', '--refresh'])).toEqual({
mode: 'resolve',
project: 'boot',
version: '4.1.1',
refresh: true,
noFetch: false,
})
})

test('reads a listing, with and without a project', () => {
expect(parseArgs(['--list'])).toEqual({ mode: 'list' })
expect(parseArgs(['--list', 'framework'])).toEqual({ mode: 'list', project: 'framework' })
})

test('rejects cache flags on a listing, which reads the catalog fresh either way', () => {
expect(parseArgs(['--list', '--no-fetch'])).toEqual({ error: '--list takes no --refresh or --no-fetch' })
expect(parseArgs(['--list', '--refresh'])).toEqual({ error: '--list takes no --refresh or --no-fetch' })
})

test('rejects a second positional after --list, rather than silently ignoring it', () => {
expect(parseArgs(['--list', 'boot', '4.1.1'])).toEqual({ error: 'unexpected argument: 4.1.1' })
})

test('still requires a version when resolving', () => {
expect(parseArgs(['boot'])).toEqual({ error: 'missing <version>' })

Check warning on line 698 in scripts/__tests__/docs.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/__tests__/docs.test.ts#L698

Non-HTML variable 'error' is used to store raw HTML
})
})
Loading
Loading