Skip to content

Commit d9f8529

Browse files
authored
feat(cli): convention-based docs discovery pipeline (#48)
* feat(cli): add intent-skills format field and tanstack intent dep (T001, T002) Phase 1 of convention-based-discovery-20260409 (#46). - NpmLockEntry gains optional format?: 'docs' | 'intent-skills' so intent-format lock entries can be distinguished from docs-format entries at sync/remove time. Default remains 'docs' for backwards compatibility. - Pin @tanstack/intent@0.0.29 as a runtime dep so the upcoming local-intent adapter can call scanLibrary / findSkillFiles / parseFrontmatter via its programmatic export. * feat(cli): add discovery adapter pipeline (T003-T010) Phase 2 of convention-based-discovery-20260409 (#46). New `packages/cli/src/discovery/` module implementing the convention scanner layer that runs before the central registry lookup: - types.ts DiscoveryResult discriminated union, adapter shapes - conventions.ts local + repo path tables, exclusion filter - quality.ts >=3 md files OR >=4 KiB threshold scorer (SC-3 guard) - local-ask.ts reads package.json.ask.docsPath opt-in - local-intent.ts wraps @tanstack/intent findSkillFiles/parseFrontmatter with zod runtime validation on the frontmatter - local-conventions dist/docs -> docs, README fallback with a warning - repo-conventions post-tarball scan of github repo trees - index.ts runLocalDiscovery / runRepoDiscovery orchestrators Adapters are not wired into the CLI dispatcher yet — that is T013-T014 in the next phase. This commit is pure additive code: build and lint stay green and no existing behaviour changes. * feat(cli): dispatch local discovery, intent-skills writer, sync/remove (T011-T013, T015-T016) Phase 3 of convention-based-discovery-20260409 (#46). Wires the Phase 2 discovery adapters into the CLI dispatcher and adds the AGENTS.md intent-skills block writer. T014 (repo-conventions via github source) is deferred — see Surprises & Discoveries in the plan. - agents-intent.ts (new) upsertIntentSkillsBlock / removeFromIntentSkillsBlock manage the <!-- intent-skills:start --> ... <!-- intent-skills:end --> marker block on a byte range strictly disjoint from the existing BEGIN:ask-docs-auto-generated block. Preserves foreign-package entries on upsert; strips the whole block when the last entry for a package is removed. - skill.ts generateSkill gains an optional GenerateSkillOptions.docsDir. When set, the skill file references the provided dir in place and omits the 'When the docs cannot be found' fallback section. - index.ts New handleLocalDiscovery helper dispatches DiscoveryResult: - kind: 'docs' runs the existing ask pipeline with a synthetic FetchResult (installPath propagated to the lock entry). - kind: 'intent-skills' records an npm lock entry with format: 'intent-skills' and upserts the marker block only — no .ask/docs/ copy, no .claude/skills/ generation. addCmd.run calls runLocalDiscovery for `npm:` ecosystem specs with no --source / --docs-path override, before the github fast-path and the registry auto-detect. On hit, returns early. removeCmd.run branches on the lock entry's format: intent-skills entries call removeFromIntentSkillsBlock and drop the lock row; 'docs' entries keep the existing delete path. runSync adds a second pass that iterates lock entries with format: 'intent-skills', re-runs localIntentAdapter against each installed package, and refreshes the marker block + lock. Build, lint, and the 237 existing tests stay green. * test(cli): unit coverage for discovery, quality, and agents-intent (T017-T024) Phase 4 of convention-based-discovery-20260409 (#46). - test/discovery/quality.test.ts (new, 9 cases) Covers the SC-3 guard: noise-only (CONTRIBUTING.md + CHANGELOG.md + LICENSE) repos score below threshold and fail through. Also verifies the >=3 count and >=4 KiB byte fallbacks, nested walking, .mdx support, and the LICENSE/CODE_OF_CONDUCT/SECURITY exclusions. - test/discovery/adapters.test.ts (new, 12 cases) localAskAdapter: no manifest -> null, valid ask.docsPath -> docs result with installPath, broken path -> null. localConventionsAdapter: dist/docs selection with quality pass, README fallback when conventions are noise-only, SC-3 noise repo returns null. runLocalDiscovery: priority order (local-ask > local-conventions), explicitDocsPath bypass, missing-package null. - test/agents-intent.test.ts (new, 12 cases) upsertIntentSkillsBlock: creates file, is idempotent, preserves siblings, replaces only target entries, preserves bytes outside the block, handles scoped packages via load-path prefix match, escapes double quotes and backslashes. removeFromIntentSkillsBlock: returns false when absent, strips only target entries, strips the whole block when the last entry is removed. Fixture tasks T017-T020 are satisfied via inline tmp fixtures inside the adapter / quality tests (bun:test pattern used throughout this project; no new packages/cli/test/fixtures/ directories). T024 orchestration tests are consolidated into the `runLocalDiscovery` describe block in adapters.test.ts. T025 integration: the 237 pre-refactor tests still pass unchanged, which satisfies SC-4. An end-to-end `ask docs add npm:<pkg>` walk- through against the new fixtures is deferred to a follow-up. Build, lint, and all 267 tests (237 pre-existing + 30 new) green. * chore(cli): add coverage audit script and document discovery pipeline (T026, T029, T030) Phase 5 closeout of convention-based-discovery-20260409 (#46). - packages/cli/scripts/audit-coverage.ts (new) Scaffold for SC-1: walks apps/registry/content/registry/**/*.md, extracts {owner, repo, npm alias, docsPath} from each entry, and for every entry with an npm alias runs runLocalDiscovery against the installed package in node_modules. Emits one JSON line per entry plus a summary row, exits 1 when coverage < 80 %. The live run (T027) requires all 37 registry packages installed locally and is deferred to a follow-up CI job. - CLAUDE.md New Gotchas entries describing the discovery pipeline order, the dual AGENTS.md marker blocks (ask-docs-auto-generated vs intent-skills), and the NpmLockEntry.format field. Existing entries about curated-npm strategy and other layers are untouched. - T029 verification: tsc + eslint + 267 bun:test cases all green across packages/cli after the full Phase 1-5 landing. Status of deferred work: - T014 repo-conventions wiring through GithubSource - T025 end-to-end ask docs add integration test - T027 live coverage audit run - T028 live intent-CLI parity diff All four are bounded, low-risk follow-ups with the core path in place. See Surprises & Discoveries in the plan for details. * test(cli): verify marker isolation between ask-docs and intent-skills blocks Self-review add-on for convention-based-discovery-20260409 (#46). Hardens the 'marker isolation' invariant documented in the spec: neither writer (agents.ts BEGIN:ask-docs-auto-generated nor agents-intent.ts intent-skills:start) is allowed to touch the other region. The new case seeds AGENTS.md with a real ask-docs block, runs an intent-skills upsert + remove cycle, and asserts the ask block is preserved byte-for-byte across both operations. * fix(cli): apply gemini review suggestions Two Important findings from gemini review of PR #48: - agents-intent.ts readExistingBlock: anchor END_MARKER search after BEGIN_MARKER. Without the offset, a malformed AGENTS.md with two intent-skills blocks (e.g. from a failed partial write) could match an END_MARKER belonging to a different block and produce a corrupt splice. - repo-conventions.ts collectDocFiles: add MAX_WALK_DEPTH=20 guard against symlink loops the tarball extractor failed to resolve and pathological monorepo layouts. Real docs trees rarely exceed ~6 levels deep, so 20 is both generous and bounded. Build, lint, and 268 tests still green. * chore(track): convention-based-discovery-20260409 PR submitted - Retrospective added to plan - Track moved active/ -> completed/ - metadata status -> review, pr -> #48 * fix(cli): apply cubic review findings (7 threads) 7 unresolved cubic threads on PR #48, all addressed: P1 - local-conventions.ts: validate symlink realpath containment before scoreDirectory walks the candidate. A symlinked `dist/docs -> /etc` would otherwise let the scorer recurse out of the package directory before tryLocalRead's later guard rejected the read. P1 - index.ts runSync: hoist intent-skills lock-key collection above the empty-config early-exit so intent-only projects (no `config.docs` rows) still get their marker block resynced. P2 - conventions.ts: case-insensitive meta-filename exclusion. Lowercase `contributing.md` / `changelog.md` were bypassing the SC-3 filter and inflating the quality score. Stored lowercase in EXCLUDED_EXACT_LOWER and matched via toLowerCase(). P2 - audit-coverage.ts: registry aliases are structured YAML objects (`ecosystem: npm` + `name: <name>`), not the `- npm:<name>` shorthand the earlier regex expected. Updated to match the two-line ecosystem/name pair. P2 - agents-intent.ts: replace the lossy two-pass `unescapeDq` with a single-pass character-by-character decoder. The old implementation `replace(/\\\\/g, '\\').replace(/\\"/g, '"')` would consume backslashes the first pass produced, corrupting round-trips for tasks containing a literal backslash adjacent to a quote (e.g. `\"`). Regression test added. P2 - repo-conventions.ts + quality.ts: share MAX_WALK_DEPTH=20 between scoreDirectory and collectDocFiles via conventions.ts. Previously only collectDocFiles had a depth bound, so scoreDirectory could accept a deep tree whose files collectDocFiles later refused to read. P3 - adapters.test.ts: replace conditional `if kind === 'docs'` assertion blocks with explicit narrowing throws so a wrong- kind regression fails the test instead of silently passing. Also added a lowercase-noise SC-3 case for the case-insensitive filter. Build, lint, and 270 tests (267 + 3 new regression cases) green.
1 parent 22297a5 commit d9f8529

22 files changed

Lines changed: 2188 additions & 78 deletions

File tree

.please/docs/tracks/active/convention-based-discovery-20260409/metadata.json renamed to .please/docs/tracks/completed/convention-based-discovery-20260409/metadata.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"track_id": "convention-based-discovery-20260409",
33
"type": "refactor",
4-
"status": "planned",
4+
"status": "review",
55
"created_at": "2026-04-09T18:30:00Z",
6-
"updated_at": "2026-04-09T18:45:00Z",
6+
"updated_at": "2026-04-10T00:00:00Z",
77
"issue": "#46",
8-
"pr": "",
8+
"pr": "#48",
99
"project": ""
1010
}

.please/docs/tracks/active/convention-based-discovery-20260409/plan.md renamed to .please/docs/tracks/completed/convention-based-discovery-20260409/plan.md

Lines changed: 95 additions & 36 deletions
Large diffs are not rendered by default.

.please/docs/tracks/active/convention-based-discovery-20260409/spec.md renamed to .please/docs/tracks/completed/convention-based-discovery-20260409/spec.md

File renamed without changes.

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ node packages/cli/dist/index.js docs add <spec> -s <source> [options]
6464
- `.claude/agent-memory/` IS committed to git (not ignored) — it persists agent learnings across sessions.
6565
- 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`).
6666
- `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.
67+
- 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/`.
68+
- 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.
69+
- `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`.
6770
- 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.
6871
- `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`.
6972
- `@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.

bun.lock

Lines changed: 8 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
},
3636
"dependencies": {
3737
"@pleaseai/ask-schema": "workspace:*",
38+
"@tanstack/intent": "0.0.29",
3839
"citty": "^0.2.2",
3940
"consola": "^3.4.2",
4041
"node-html-markdown": "^1.3.0",
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Coverage audit for SC-1 of convention-based-discovery-20260409 (#46).
4+
*
5+
* Iterates every entry in `apps/registry/content/registry/**\/*.md`,
6+
* extracts `{owner, repo, aliases, docsPath}`, and for each entry
7+
* attempts to resolve the docs via the **convention scan alone** (no
8+
* registry call). A resolution counts as "covered" when the scan
9+
* produces a `kind: 'docs'` DiscoveryResult whose file list is
10+
* non-empty.
11+
*
12+
* This script is a scaffold: running it against the real registry
13+
* requires a sandbox with the packages installed to `node_modules/` so
14+
* `localAskAdapter` / `localConventionsAdapter` can read them in place.
15+
* CI integration is left as a follow-up (see Phase 5 track notes).
16+
*
17+
* Usage (from repo root):
18+
* bun run packages/cli/scripts/audit-coverage.ts [--registry <dir>]
19+
*
20+
* Output (stdout):
21+
* one JSON line per entry: {entry, covered, adapter, reason}
22+
* final line: {total, covered, percentage}
23+
*
24+
* Exit code:
25+
* 0 if percentage >= 80
26+
* 1 otherwise (SC-1 failure)
27+
*/
28+
29+
import fs from 'node:fs'
30+
import path from 'node:path'
31+
import process from 'node:process'
32+
import { runLocalDiscovery } from '../src/discovery/index.js'
33+
34+
interface RegistryEntry {
35+
slug: string
36+
owner: string
37+
repo: string
38+
npmName?: string
39+
docsPath?: string
40+
}
41+
42+
/**
43+
* Registry alias entries are structured YAML objects, not the shorthand
44+
* `- npm:<name>` strings an earlier draft of this script expected. A
45+
* typical entry looks like:
46+
*
47+
* aliases:
48+
* - ecosystem: npm
49+
* name: zod
50+
*
51+
* The regex below matches that two-line pair anywhere in the
52+
* frontmatter. `[\s\S]*?` is a non-greedy wildcard that lets the
53+
* `name:` line be any distance below the `ecosystem: npm` line (the
54+
* real registry files put them on adjacent lines, but staying lenient
55+
* keeps the audit robust across hand-edits).
56+
*/
57+
const NPM_ALIAS_RE = /ecosystem:\s*npm[\s\S]*?name:\s*['"]?([^'"\s]+)['"]?/g
58+
const REPO_RE = /^repo:\s*['"]?([^'"\s]+)['"]?/m
59+
const DOCS_PATH_RE = /^docsPath:\s*['"]?([^'"\s]+)['"]?/m
60+
61+
function parseEntry(mdPath: string): RegistryEntry | null {
62+
const content = fs.readFileSync(mdPath, 'utf-8')
63+
const fmEnd = content.indexOf('\n---', 4)
64+
if (fmEnd === -1) {
65+
return null
66+
}
67+
const frontmatter = content.slice(0, fmEnd)
68+
const repoMatch = REPO_RE.exec(frontmatter)
69+
if (!repoMatch) {
70+
return null
71+
}
72+
const [owner, repo] = repoMatch[1]!.split('/')
73+
if (!owner || !repo) {
74+
return null
75+
}
76+
const docsPathMatch = DOCS_PATH_RE.exec(frontmatter)
77+
const npmAliases: string[] = []
78+
NPM_ALIAS_RE.lastIndex = 0
79+
let m: RegExpExecArray | null
80+
// eslint-disable-next-line no-cond-assign
81+
while ((m = NPM_ALIAS_RE.exec(frontmatter))) {
82+
npmAliases.push(m[1]!)
83+
}
84+
return {
85+
slug: `${owner}/${repo}`,
86+
owner,
87+
repo,
88+
npmName: npmAliases[0],
89+
docsPath: docsPathMatch?.[1],
90+
}
91+
}
92+
93+
async function auditEntry(
94+
entry: RegistryEntry,
95+
projectDir: string,
96+
): Promise<{ covered: boolean, adapter?: string, reason?: string }> {
97+
const pkgName = entry.npmName
98+
if (!pkgName) {
99+
return { covered: false, reason: 'no npm alias' }
100+
}
101+
try {
102+
const result = await runLocalDiscovery({
103+
projectDir,
104+
pkg: pkgName,
105+
requestedVersion: 'latest',
106+
})
107+
if (!result) {
108+
return { covered: false, reason: 'discovery miss' }
109+
}
110+
if (result.kind !== 'docs') {
111+
return { covered: false, reason: `unexpected kind: ${result.kind}` }
112+
}
113+
if (result.files.length === 0) {
114+
return { covered: false, reason: 'empty file list' }
115+
}
116+
return { covered: true, adapter: result.adapter }
117+
}
118+
catch (err) {
119+
return {
120+
covered: false,
121+
reason: `error: ${err instanceof Error ? err.message : String(err)}`,
122+
}
123+
}
124+
}
125+
126+
async function main(): Promise<void> {
127+
const args = process.argv.slice(2)
128+
let registryDir = 'apps/registry/content/registry'
129+
const idx = args.indexOf('--registry')
130+
if (idx !== -1 && args[idx + 1]) {
131+
registryDir = args[idx + 1]!
132+
}
133+
if (!fs.existsSync(registryDir)) {
134+
console.error(`registry dir not found: ${registryDir}`)
135+
process.exit(2)
136+
}
137+
138+
const mdFiles: string[] = []
139+
const walk = (dir: string): void => {
140+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
141+
const full = path.join(dir, e.name)
142+
if (e.isDirectory()) {
143+
walk(full)
144+
}
145+
else if (e.isFile() && e.name.endsWith('.md')) {
146+
mdFiles.push(full)
147+
}
148+
}
149+
}
150+
walk(registryDir)
151+
152+
const projectDir = process.cwd()
153+
let total = 0
154+
let covered = 0
155+
for (const md of mdFiles) {
156+
const entry = parseEntry(md)
157+
if (!entry) {
158+
continue
159+
}
160+
total++
161+
const result = await auditEntry(entry, projectDir)
162+
if (result.covered) {
163+
covered++
164+
}
165+
console.log(
166+
JSON.stringify({ entry: entry.slug, npmName: entry.npmName, ...result }),
167+
)
168+
}
169+
const percentage = total === 0 ? 0 : Math.round((covered / total) * 100)
170+
console.log(JSON.stringify({ total, covered, percentage }))
171+
process.exit(percentage >= 80 ? 0 : 1)
172+
}
173+
174+
main().catch((err) => {
175+
console.error(err)
176+
process.exit(3)
177+
})

0 commit comments

Comments
 (0)