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
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"track_id": "convention-based-discovery-20260409",
"type": "refactor",
"status": "planned",
"status": "review",
"created_at": "2026-04-09T18:30:00Z",
"updated_at": "2026-04-09T18:45:00Z",
"updated_at": "2026-04-10T00:00:00Z",
"issue": "#46",
"pr": "",
"pr": "#48",
"project": ""
}

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ node packages/cli/dist/index.js docs add <spec> -s <source> [options]
- `.claude/agent-memory/` IS committed to git (not ignored) — it persists agent learnings across sessions.
- When pinning GitHub Actions by SHA, verify via `gh api repos/<owner>/<action>/git/refs/tags/<tag> -q .object.sha` — bogus SHAs with correct-looking prefixes have slipped in before (e.g. `actions/setup-node@v4.4.0` real SHA is `49933ea5288caeca8642d1e84afbd3f7d6820020`).
- `ask docs add|sync|remove` auto-manages ignore files to mark `.ask/docs/` as vendored. Writes nested configs inside `.ask/docs/` (`.gitattributes`, `eslint.config.mjs`, `biome.json`, `.markdownlint-cli2.jsonc`) and patches root `.prettierignore`/`sonar-project.properties`/`.markdownlintignore` via a marker block (`# ask:start ... # ask:end`). Disable via `manageIgnores: false` in `.ask/config.json`. Do not hand-edit inside the marker blocks — `sync`/`remove` will overwrite them.
- Convention-based discovery (`packages/cli/src/discovery/`) runs BEFORE the central registry lookup for `npm:` ecosystem specs without `--source`/`--docs-path`. Adapter priority: `local-ask` (`package.json.ask.docsPath` opt-in) → `local-intent` (packages with `keywords: ['tanstack-intent']`, wrapped around `@tanstack/intent`'s `findSkillFiles`/`parseFrontmatter`) → `local-conventions` (`dist/docs` → `docs` → `README.md` fallback with warning). First non-null wins; adapters never override earlier ones. Registry is demoted to fallback. See spec + plan in `.please/docs/tracks/active/convention-based-discovery-20260409/`.
- Intent-format packages use a separate AGENTS.md block (`<!-- intent-skills:start --> ... <!-- intent-skills:end -->`) managed by `packages/cli/src/agents-intent.ts`. The writer preserves entries from foreign packages on upsert and strips the whole block when the last entry for the target package is removed. Operates on a byte range strictly disjoint from the existing `<!-- BEGIN:ask-docs-auto-generated -->` block in `agents.ts` — neither writer touches the other region.
- `NpmLockEntry` has an optional `format?: 'docs' | 'intent-skills'` field. Default is `'docs'`, so pre-refactor lock entries load unchanged. `ask docs sync` iterates lock entries with `format: 'intent-skills'` in a second pass and re-runs `localIntentAdapter` for each; `ask docs remove` branches on the format to dispatch either the normal delete path or `removeFromIntentSkillsBlock`.
- Registry strategy selection (`packages/cli/src/registry.ts:selectBestStrategy`) prefers a "curated npm" strategy (`source: 'npm'` with explicit `docsPath`) over github, even when github is listed first. Without `docsPath`, the static priority table (github > npm > web > llms-txt) wins. This is what makes `vercel/ai`'s `dist/docs` actually load from npm.
- `NpmSource.fetch` (`packages/cli/src/sources/npm.ts`) is local-first: it reads `node_modules/<pkg>/<docsPath>` directly when the installed `package.json` version satisfies the request, and only falls through to a tarball download on miss. The lock entry records `installPath` instead of `tarball` for the local case — `NpmLockEntry` in `packages/schema/src/lock.ts` accepts either, validated in `buildLockEntry`. Do not assume the lock entry always has `tarball`.
- `@nuxt/test-utils` pulls `h3-next` (npm alias → `h3@2.0.1-rc.*`) which collides with the h3 v1 that nitro/nuxt-content use. Runtime symptom: `event.req.headers.entries is not a function` thrown from `@nuxt/content`'s `fetchContent`, surfacing as Registry API 500/hang. Mitigation: a `bun patch` strips `h3-next` from `@nuxt/test-utils/package.json` (`patches/@nuxt%2Ftest-utils@4.0.0.patch`) AND a root `postinstall` removes `node_modules/.bun/h3@2.0.1-rc.*`. Bun 1.3.11 `overrides` don't reliably apply to transitive deps, so the postinstall is load-bearing. Bump the version glob when @nuxt/test-utils upgrades.
Expand Down
15 changes: 8 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"dependencies": {
"@pleaseai/ask-schema": "workspace:*",
"@tanstack/intent": "0.0.29",
"citty": "^0.2.2",
"consola": "^3.4.2",
"node-html-markdown": "^1.3.0",
Expand Down
177 changes: 177 additions & 0 deletions packages/cli/scripts/audit-coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env bun
/**
* Coverage audit for SC-1 of convention-based-discovery-20260409 (#46).
*
* Iterates every entry in `apps/registry/content/registry/**\/*.md`,
* extracts `{owner, repo, aliases, docsPath}`, and for each entry
* attempts to resolve the docs via the **convention scan alone** (no
* registry call). A resolution counts as "covered" when the scan
* produces a `kind: 'docs'` DiscoveryResult whose file list is
* non-empty.
*
* This script is a scaffold: running it against the real registry
* requires a sandbox with the packages installed to `node_modules/` so
* `localAskAdapter` / `localConventionsAdapter` can read them in place.
* CI integration is left as a follow-up (see Phase 5 track notes).
*
* Usage (from repo root):
* bun run packages/cli/scripts/audit-coverage.ts [--registry <dir>]
*
* Output (stdout):
* one JSON line per entry: {entry, covered, adapter, reason}
* final line: {total, covered, percentage}
*
* Exit code:
* 0 if percentage >= 80
* 1 otherwise (SC-1 failure)
*/

import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { runLocalDiscovery } from '../src/discovery/index.js'

interface RegistryEntry {
slug: string
owner: string
repo: string
npmName?: string
docsPath?: string
}

/**
* Registry alias entries are structured YAML objects, not the shorthand
* `- npm:<name>` strings an earlier draft of this script expected. A
* typical entry looks like:
*
* aliases:
* - ecosystem: npm
* name: zod
*
* The regex below matches that two-line pair anywhere in the
* frontmatter. `[\s\S]*?` is a non-greedy wildcard that lets the
* `name:` line be any distance below the `ecosystem: npm` line (the
* real registry files put them on adjacent lines, but staying lenient
* keeps the audit robust across hand-edits).
*/
const NPM_ALIAS_RE = /ecosystem:\s*npm[\s\S]*?name:\s*['"]?([^'"\s]+)['"]?/g
const REPO_RE = /^repo:\s*['"]?([^'"\s]+)['"]?/m
const DOCS_PATH_RE = /^docsPath:\s*['"]?([^'"\s]+)['"]?/m

function parseEntry(mdPath: string): RegistryEntry | null {
const content = fs.readFileSync(mdPath, 'utf-8')
const fmEnd = content.indexOf('\n---', 4)
if (fmEnd === -1) {
return null
}
const frontmatter = content.slice(0, fmEnd)
const repoMatch = REPO_RE.exec(frontmatter)
if (!repoMatch) {
return null
}
const [owner, repo] = repoMatch[1]!.split('/')
if (!owner || !repo) {
return null
}
const docsPathMatch = DOCS_PATH_RE.exec(frontmatter)
const npmAliases: string[] = []
NPM_ALIAS_RE.lastIndex = 0
let m: RegExpExecArray | null
// eslint-disable-next-line no-cond-assign
while ((m = NPM_ALIAS_RE.exec(frontmatter))) {
npmAliases.push(m[1]!)
}
return {
slug: `${owner}/${repo}`,
owner,
repo,
npmName: npmAliases[0],
docsPath: docsPathMatch?.[1],
}
}

async function auditEntry(
entry: RegistryEntry,
projectDir: string,
): Promise<{ covered: boolean, adapter?: string, reason?: string }> {
const pkgName = entry.npmName
if (!pkgName) {
return { covered: false, reason: 'no npm alias' }
}
try {
const result = await runLocalDiscovery({
projectDir,
pkg: pkgName,
requestedVersion: 'latest',
})
if (!result) {
return { covered: false, reason: 'discovery miss' }
}
if (result.kind !== 'docs') {
return { covered: false, reason: `unexpected kind: ${result.kind}` }
}
if (result.files.length === 0) {
return { covered: false, reason: 'empty file list' }
}
return { covered: true, adapter: result.adapter }
}
catch (err) {
return {
covered: false,
reason: `error: ${err instanceof Error ? err.message : String(err)}`,
}
}
}

async function main(): Promise<void> {
const args = process.argv.slice(2)
let registryDir = 'apps/registry/content/registry'
const idx = args.indexOf('--registry')
if (idx !== -1 && args[idx + 1]) {
registryDir = args[idx + 1]!
}
if (!fs.existsSync(registryDir)) {
console.error(`registry dir not found: ${registryDir}`)
process.exit(2)
}

const mdFiles: string[] = []
const walk = (dir: string): void => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name)
if (e.isDirectory()) {
walk(full)
}
else if (e.isFile() && e.name.endsWith('.md')) {
mdFiles.push(full)
}
}
}
walk(registryDir)

const projectDir = process.cwd()
let total = 0
let covered = 0
for (const md of mdFiles) {
const entry = parseEntry(md)
if (!entry) {
continue
}
total++
const result = await auditEntry(entry, projectDir)
if (result.covered) {
covered++
}
console.log(
JSON.stringify({ entry: entry.slug, npmName: entry.npmName, ...result }),
)
}
const percentage = total === 0 ? 0 : Math.round((covered / total) * 100)
console.log(JSON.stringify({ total, covered, percentage }))
process.exit(percentage >= 80 ? 0 : 1)
}

main().catch((err) => {
console.error(err)
process.exit(3)
})
Loading