Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
- An educational documentation site built from
docs/with sitekit, insite/with its own dependencies. It has three sections: Docs (a page per command, the JSDoc vs TSDoc concepts, the CLI reference and CI integration), Tutorials (guided walkthroughs of the migration, the stubs, the CI gate andescalate) and Examples (before and after taken from real runs). Pages are grouped insite/sitekit.config.json, and a page that is in no group fails the build. Everything it needs is a public package: no token and no private registry, so it builds the same for forks and Dependabot. CI builds it on every pull request that touches the docs and runssitekit checkon the result, anddocs.ymlpublishes it to GitHub Pages.AGENTS.mdis excluded from it. - The docs site is static: every page is a real HTML file with its own
<title>and description and its content in the HTML,404.htmlis the not-found page, and the footer links to the repository and the issue tracker.
Changed
- The documentation site is upgraded to docs-sitekit 0.2.4, and a shared link to it now shows a picture:
site.imagenames/social.png, a 1200 by 630 card that the docs workflow draws with social-card-action, so the pages haveog:image,twitter:imageand a large Twitter card. - Node 20 is no longer supported:
engines.nodeis now>=22.13. Node 20 reached end of life, and the dev tooling no longer supports it: vitest 5 requires^22.12 || ^24 || >=26and ESLint 10^22.13. The CI matrix runs Node 22 and 24. Consumers on Node 20 need to upgrade Node, so this ships in the next major release. - vitest and
@vitest/coverage-v8are updated to 5 together. Dependabot now groups them, because they require the exact same version and a major bump cannot pass as two pull requests. - The CLI’s own
check, ESLint, Prettier and the CI-integration workflow excludesite/, which is its own project. - The documentation site is upgraded to docs-sitekit 0.2.3. The footer line has no copyright sign and no year, the search button is named
Search, and the two side menus of a doc page are no longer unnamed landmarks, because the navigation inside each one carries the name.
[1.0.0] - 2026-09-09
First stable release. Everything below accumulated since 0.2.1 as the [Unreleased] backlog AGENTS.md describes as deliberately deferred — held until there was enough of it to call a stable line.
Added
-
Every release now also publishes to GitHub Packages, mirroring npmjs.com.
publish.ymlgains a second job,publish-github-packages, that runs after the npmjs publish succeeds and republishes the same build under the scoped name GitHub Packages requires (@miguelcolmenares/jsdoc-to-tsdoc— its npm registry rejects an unscoped name outright). The rename happens only inside that job’s own checkout vianpm pkg set, so it never touches the committedpackage.jsonor the npmjs publish, which keeps the unscopedjsdoc-to-tsdocname. Auth is the workflow’s ownGITHUB_TOKEN(packages: write) — GitHub Packages has no OIDC trusted-publishing story yet, unlike the npmjs job. -
scan --classify --enrich=copilot|ollama|anthropic: opt-in LLM suggestions for the LOW-confidence and STALE cases classification already finds (#55). The deterministic pipeline is unchanged either way —scaffold’s name inference andconvert’s mechanical rewrites run exactly as before, with zero LLM dependency, matchingAGENTS.md§4 decision #9 (“No LLM dependency”).--enrichonly ever runs when the flag is passed: omitting it never imports, constructs, or calls into the newenricherdomain at all, which is what the opt-in gating test insrc/commands/__tests__/enrich.test.tspins directly rather than trusting by inspection. Three providers, one shared prompt: the GitHub Copilot CLI (copilot, if on$PATH, no API key), a local Ollama daemon (ollama, no API key), and the Anthropic API (anthropic, viaANTHROPIC_API_KEY). Every provider is fallible by design (AGENTS.md§4 decision #10) — a missing binary, an unreachable daemon, a missing key, or an uninstalled SDK is reported per-target as{ ok: false, reason: "unavailable" | "error", detail }, never a throw, so a run against a provider that isn’t set up still prints the full deterministic report with “Enrichment unavailable for N entries” rather than crashing or hanging.scanstays read-only: a suggestion is surfaced as a new, additiveenrichmentfield on the flagged declaration in the human table,--report=json, and--report=md— it is never written to a file.@anthropic-ai/sdkis adevDependency, imported lazily from inside theanthropicprovider and listed inbuild.config.ts’sexternals, so a consumer who never passes--enrich=anthropicneither downloads it nor ships it in the bundle (still under the 500 KB gzipped bound). The OpenAI half of the issue’s “Anthropic / OpenAI” framing is deliberately out of scope here: the issue’s own flag enum only namesanthropic, and an equally-shaped OpenAI provider is recorded as a follow-up inAGENTS.md§11. -
A prebuilt GitHub Action and Bitbucket Pipe, wrapping
checkandscan --classifyso a consuming repo’s CI doesn’t have to hand-roll thenpxinvocation or its exit-code handling. A composite action at the repo root (action.yml,uses: miguelcolmenares/jsdoc-to-tsdoc@main) covers the same surfacecheckandscan --fail-on-missing/--fail-on-stalealready ship: which command to run,--cwd,--only/--exclude,--report, and a Node version — and it propagates the CLI’s own exit code (0/1/2/3) as the step’s own success or failure rather than swallowing it. The Bitbucket Pipe (pipe/) ships as source only — a Dockerfile,pipe.yml, andpipe.shreading the same flags from Bitbucket’s standard pipe-variable env vars, verified by building the image and smoke-running it against this repo’s ownsrc/in CI — but it is not yet published to the Bitbucket Pipe marketplace or any Docker registry, since that needs the maintainer’s own Docker Hub and Atlassian Marketplace accounts.pipe/README.mdcarries the exact publish checklist. Both wrappers are exercised in CI against this repo’s own source: the action via a localuses: ./step, the pipe viadocker build+ a smoke run — the closest either gets to a real integration test without the accounts a live publish would need. -
merge-driver— a git merge driver that auto-resolves the one line a long-lived escalation branch’s rebase ever conflicts on.escalatedocumentstsdoc-require-2/require’s"warn"→"error"flip as “the only line that ever conflicts on a long-lived migration branch” — two branches (or a branch and a rebasedmain) each escalate the rule independently, and a plain rebase stops for a human to resolve a conflict whose answer is always “whichever side sayserror”.resolveSeverityConflict(new, inescalator/) is the pure, testable core: given the three versions git’s merge-driver protocol provides, it resolves when the only difference is that one severity value — preferringerror, since escalation only ever moves one direction — and refuses (returnsnull) the moment anything else on the file differs, including anoffon either side. A thinmerge-driversubcommand wraps it in git’s file-based merge-driver protocol (%O %A %B); on refusal it hands the same three files togit merge-file, so the result is the ordinary conflict markers a rebase with no driver configured would have produced, never a silent guess. Wiring it in is a one-time local step documented in the README — a.gitattributesline can be committed, but thegit config merge.<name>.driverregistration cannot, since git deliberately does not let a committed file alone make a clone run an arbitrary command during merge.initdoes not wire this up automatically for the same reason. -
scaffold --membersdocuments an interface’s (or type-literal alias’s) individual members, not just its header. Previouslyscaffoldwrote one stub forexport interface HeroSectionProps— “Hero section props.” — no matter how many properties it declared; a wide, undocumented interface still needed every member filled in by hand. This was noted as deliberately out of scope in adeclaration-shape.tscomment (describeStatement’s interface branch never split docs onto members), because member enumeration and per-declaration insertion were, at the time, two different code paths with nothing forcing them to agree on what counts as an addressable member. What made it safe to close now:member-targets.ts(built for relocating an existing@propertydescription onto a member) andinsertion-location.ts(built for placing a stub above a declaration) turned out to already answer both halves of the problem generically enough to reuse one level down — a newmember-declarations.tscombines them intoMemberDeclaration, and a newmember-stub-builder.tsreusesstub-builder.ts’s own rendering and line-placement rather than re-deriving either. A member whose declared type is callable (a method signature, or a property typed as a function) gets@param/@returnstags the same way a function declaration would; a plain data property gets a noun-phrase summary — inferred purely from the member’s name and declared type, never a guess at what it means. A member that already has its own doc comment is left alone, independent of whether the interface’s own header is documented — the two are separate gaps. Applies equally to interfaces and type-literal aliases, since the scanner already treats the two identically for member purposes. Off by default: a per-member stub can multiply a run’s output (one comment per property instead of one per declaration), the same reasoning that keepsconvert --promote-line-commentsopt-in. Closes #62. -
checkresolvestsdoc.jsonper file now, so a monorepo can give each package its own custom tags and scope instead of sharing one project-root file.createTsdocValidator(cwd)always loaded exactly one config and reused it for the whole run — the assumptioninitandcheckboth made and #57 named directly. Each file now walks up from its own directory toward the project root looking for the nearesttsdoc.json, the same model ESLint’s flat config andtsconfig.jsonresolution both use, and falls back to the root’s config when nothing nearer exists — which is exactly what a single-root project (no nestedtsdoc.json) has always done, so that case is unchanged byte-for-byte: same validator behavior, same exit codes, same messages. The newcreateTsdocValidatorResolvercaches the walk and the built validator per directory, so a package with hundreds of files under one config pays the filesystem and parse cost once, not per file. A broken config — root or nested — still aborts the whole run with exit2before anything is reported, on the reasoning that a config that failed to load makes every file under it as untrustworthy as the single-root case already treated a broken root config; the message now names whichtsdoc.jsonfailed when it isn’t the root’s.--report=jsongainstsdocConfigs, the sorted list of every config actually applied, alongside the existing single-pathtsdocConfig.init,convert,scaffoldandescalateare unchanged and still assume one project-wide config — generating/patching atsdoc.jsonand an ESLint flat config per workspace is a separately scoped problem (“which flat config applies to which workspace” has no equivalent nearest-ancestor answer) and stays tracked as follow-up work inAGENTS.md§11.
Fixed
-
--commit-per-file’s git calls inheritedGIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILEfrom the invoking process, so running the CLI from inside a git hook silently operated on the hook’s own repository instead of the target directory. A parent git process (apre-push/pre-commithook,git rebase --exec) sets these for every child it spawns to skip normal cwd-based discovery — exactly the caseexecFile("git", args, { cwd })does not defend against on its own. This surfaced indirectly:src/committer/__tests__/git.test.tsbuilds real temp-directory repos with nestedgit init/git commitcalls, and those calls picked up this repository’s ownpre-pushhook environment when the suite ran nested inside that hook, failing withfatal: not a git repositoryagainst a path that was never asked for.src/committer/git.tsnow strips the discovery-override variables from the environment it passes to everygitcall, socwdalone decides which repository is targeted, regardless of what environment the invoking process leaked. Two regression tests reproduce the leak directly (polluteprocess.envwith the same variables a hook would set, assertensureCommittable/commitFilestill targetcwd’s repo) rather than relying on a hook actually being present to exercise it. -
The same
GIT_DIR/GIT_WORK_TREEleak also brokesrc/commands/__tests__/commit-per-file.test.ts, which has its own separate copy of the same temp-repo-buildinggit()helper rather than importing the one fixed above — a grep for every other spawn ofgitviaexecFileinsrc/after that fix turned up this second, independent occurrence. Same fix: the discovery-override variables are stripped from its environment too. -
@throws {SyntaxError}survived conversion and produced two syntax errors, becauseremove-type-bracescovered@param,@returns,@property,@typeParamand@typebut not@throws— the official parser then read the{as opening an inline tag. Widening that rule was the wrong fix: it strips braces precisely because the TypeScript signature still carries the type, and a thrown type appears in no signature, so stripping would have deleted the only information the tag holds. A newlink-throws-typerule rewrites it into TSDoc’s own form,@throws {@link SyntaxError}, which parses cleanly and keeps the type as a reference. A type that could never resolve — a primitive, a union, a generic — becomes plain prose instead, because a link to one is valid syntax that renders permanently broken, asserting a reference that does not exist. An existing{@link}is left alone, so re-runningconvertis a no-op. -
@exceptionwas neither renamed nor removed, so JSDoc’s synonym for@throwsreachedcheckas an undefined tag. It now renames to@throwsbefore the type is linked. -
convertemitted a trailing space on any line a rule emptied, so it produced files that passedcheckand failed the consumer’s ownprettier --check. A doc-comment prefix greedily absorbs the whitespace after*so that content always starts at the first non-space character, which meant an emptied line reassembled as" * "rather than" *"— the format gate the migration is supposed to leave green was the one thing the tool could still turn red. The prefix is now trimmed whenever a rule empties a line, for every rule rather than the one that surfaced it. The line is kept rather than dropped, so a@descriptionwritten above its prose still separates the summary from the description instead of merging both into one paragraph. The trim is deliberately conditional on a rule having changed the line: a blank line the author wrote, trailing space and all, is still reconstructed verbatim, which preserves the round-trip property and keepsconvertfrom reporting files whose only change is whitespace it normalised on the way past. -
convertleft@summaryin place, so a file it reported as converted then failedcheckwithtsdoc-undefined-tag. TSDoc has no@summary— the leading paragraph is the summary — and the tag appeared in no list, so the two commands in the documented workflow disagreed about the same file. Its prose now survives as the comment summary, which is already how@description,@descand@classdescare treated. The rule that performs the strip built its own pattern from a hardcoded list instead of readingPREFIX_ONLY_TAGS, which is why the tag could be absent from the registry and the behaviour at once; it now derives the pattern from the registry, and a test iterates the registry so the two cannot drift apart again.convert’s no-worse-than-the-original guard did not catch this because the comment was already failing before the conversion. -
yarn dlx jsdoc-to-tsdoccrashed on startup withCannot find package 'typescript'.typescriptwas a peer dependency, and Yarn does not install peers — so thenpxinvocation the README documents everywhere worked while its Yarn equivalent did not, even in a project with TypeScript already installed, becausedlxresolves in an isolated environment that cannot see the workspace’snode_modules. (pnpm dlxwas unaffected: pnpm 8+ auto-installs peers by default, verified against the published 0.2.1.) Moved todependencieswith the same>=5.0.0 <7.0.0range. It is still never bundled (externalsinbuild.config.tsis unchanged), and the peer pattern did not apply here in the first place: nothing in the public surface accepts or returns ats.*type, so there is no compiler instance for a host project to share. -
scan --classify --report=jsonomitted every file with no exports fromfiles[]whilefilesScannedcounted them, so the array could not be reconciled with the totals and the bucket the human table has always shown could not be enumerated from the machine output at all. Every scanned file is now listed, withtopology: nullfor those, andfiles.length === filesScannedholds.filesWithoutExportsis counted from that array rather than derived asfilesScanned - files.length— subtraction could not tell “nothing to classify here” apart from “this file went missing”, so any future filter that dropped an entry would have been silently reported as a no-export file. -
The human table’s “No exports” row is now “Nothing to document”. It was accurate for a file that declares nothing and misleading for a barrel, which exports plenty while declaring nothing to attach a comment to. The
filesWithoutExportsJSON key is unchanged. -
initreported Jest’s@jest-environmentfile pragma as an unknown tag, and as@jest— truncated at the hyphen, so the reported name appeared nowhere in the source. Both actions the message offers are wrong for it: registering it intsdoc.jsondeclares a documentation tag that is not one, and removing it silently changes which environment the test runs in. Hyphenated tokens (@jest-environment,@vitest-environment,@ts-checkin a block comment) are now classified aspragma— visible in--report=json, never surfaced as a decision to make.TagClassificationgains a"pragma"member, which widens the type onaggregateCommentTags’s public result. -
initcould not patch the two ESLint flat-config shapes the common scaffolds produce, so every project using one got the “could not patch automatically” fallback and had to paste the block in by hand. The array assigned to aconstand then default-exported is whatcreate-next-appemits; the variadicdefineConfig(a, b, c)is ESLint’s own documented API and was recorded as a known limitation inAGENTS.mdsince theimage-optimizerdogfood. Theconst-array shape is matched by resolving the default-exported identifier rather than by a pattern, becauseconst \\w+ = [matches any array and would otherwise pick an unrelated one declared above the real config — a test pins that. -
findContainerInsertPointcompared a match index against an end offset when choosing between competing shapes, so with more than one recognized container it could return the insertion point of the later match. Not reachable with the previous five patterns; it would have become reachable with these two. -
scaffoldwroteTODO(tsdoc)stubs into test files thatinit’s own generated ESLint config exempts from both TSDoc rules.checkandscan --classifyalready skipped those paths, socheckwould report a missing comment insrc/, print “Runjsdoc-to-tsdoc scaffold”, andscaffoldwould answer by stubbing the whole test tree as well — creating markers no gate will ever ask anyone to fill, in files no gate grades.scaffoldnow skips test paths by default and takes--include-tests, matchingcheck.convertand the defaultscaninventory are unchanged and still include tests: they rewrite JSDoc that is already there rather than creating an obligation. The README now states which commands look at test paths and why, which was not written down anywhere. -
The startup Compiler API check only covered three of the thirty members the scanner calls, so a partial shim could satisfy it and then crash exactly as before — the failure the check exists to prevent. It now covers every runtime
ts.*member the source references, and a test derives that list fromsrc/so the guard cannot silently fall behind: adding ats.isFoo()call without a matching entry fails, and so does leaving an entry behind after the call is removed. Caught in review on #76. -
The CLI now verifies the resolved
typescriptexposes the classic Compiler API before dispatching, and exits2with a sentence naming the version and the supported range. Previously a package that imported cleanly but carried nocreateSourceFile— TypeScript 7’s restructured package, a bundler shim — surfaced asCannot read properties of undefined (reading 'TSX')several frames inside whichever command ran first, naming neither TypeScript nor the version.
Security
mainnow requires one approving review before anything merges, closing the gap where any push with green checks could land without a second set of eyes — including an agent’s owngh pr merge. Dependabot’s minor/patch auto-merge (auto-merge-dependabot.yml) is the one exception, and stays working: a new step in that workflow approves the PR itself, using a personal access token (secrets.AUTO_APPROVE_TOKEN) rather than the defaultGITHUB_TOKEN, because GitHub blocks a workflow’s own token from approving a pull request in the same repository. Repository admins can still merge without a review via an explicit override (gh pr merge --admin); the change is that the default path no longer can.- Four high-severity advisories against
fast-uri(GHSA-5jgf-p345-68v8, GHSA-f65p-4m7j-42xc, GHSA-fph4-wmhf-6fwf, GHSA-jqff-g426-hqxp), cleared by moving the lockfile from 3.1.5 to 3.1.7. It reaches this package as a transitive runtime dependency —@microsoft/tsdoc-config→ajv→fast-uri— andajv@8.18.0already permitted the patch (^3.0.1), so the lockfile was simply resolved before 3.1.6 published rather than held back by a constraint; nooverridesentry was needed to take it. All four are host-confusion and SSRF classes inresolve()andnormalize(), and none is reachable through this tool: the only URIsajvresolves here are the$id/$refof the TSDoc schema and whatever a project’s owntsdoc.jsoncarries, and nothing acts on a resolved host with an outbound request. The bump does not change what consumers install. npm ignores a dependency’s lockfile, and@microsoft/tsdoc-configis imported lazily and never bundled, so a consumer resolvesfast-urithemselves throughajv’s own range and has had the patched version since it published. What this fixes is the repository’s own installs and CI, which is also where the alerts were raised.
Documentation
- The README never said the CLI is not a project dependency. Every example used
npx, which implies it, but nothing stated it and there was no Installation section to state it in — the headings went straight from The Problem to Usage. The reference repo this tool was dogfooded on, set up by the author, carriedjsdoc-to-tsdocin itsdevDependenciesas a result. There is now an Installation section that says plainly there isn’t one, gives the invocation for all three package runners, and separates the CLI from the four packagesinitreports — those are the lint gate that outlives the migration, and confusing the two is the whole failure mode.init’s own closing output now draws the same line, since that is the moment the decision gets made. - The docs and several source comments named the private repos this tool was dogfooded against.
AGENTS.md,CHANGELOG.md,fixtures/README.md, and TSDoc comments in four transformer rules and their tests referred to them by their real names — proprietary details of a former employer’s codebases that don’t belong in a public, personal-project repo, even as measurement citations. Every reference is now anonymized (the reference repo,repo-b/repo-c/repo-d, a generic@acme/iconsscope example) while keeping every measured figure — file counts, error counts, commit SHAs — exactly as reported; only the identifying names changed.
[0.2.1] - 2026-09-02
Fixed
peerDependencies.typescripthad no upper bound (>=5.0), so a fresh/isolated install (e.g.npx jsdoc-to-tsdoc) silently resolved TypeScript 7.x as the peer once it was published. TypeScript 7’s npm package is restructured entirely around the native/Go rewrite — its"."export now resolves tolib/version.cjsinstead of the classic Compiler API — so every command that readsts.ScriptKind,ts.createSourceFile, etc. (scan,init,convert) crashed withCannot read properties of undefined (reading 'TSX')/'TS'. Capped to>=5.0.0 <7.0.0, which keeps both currently-working majors (5.x, this repo’s own pin; 6.x, confirmed working live) and excludes the broken one.
[0.2.0] - 2026-08-24
Added
convertnow proves a transformed file is no worse than the original before writing it — it runs the same official@microsoft/tsdocvalidatorcheckuses on both the original and converted file text, and skips writing (exit 1, listed on stderr) any file where the conversion introduced more violations than it started with. A safety net against a future rule bug, not a currently-triggerable path..github/instructions/architecture.instructions.mdand.github/instructions/tsdoc-gotchas.instructions.md— repository-scoped agent guidance covering DDD domains, the rule-pipeline model, and the full catalog of what breaks the officialtsdoc/syntaxparser in real code..github/skills/add-transformer-rule/— the first reusable agent skill: write, register, and test a new conversion rule.
Fixed
- The blank content line a removed tag block (
@async,@function, …) leaves behind before the closing*/is now trimmed. Valid TSDoc either way, but untidy output the tool was writing into user files.
[0.1.0] - 2026-08-20
Added
-
convert --commit-per-file/scaffold --commit-per-filecommit each changed file on its own, so a migration lands as one reviewable commit per file (docs: convert JSDoc to TSDoc in <path>/docs: add TSDoc stubs to <path>) instead of one sprawling diff. It refuses to start unless the target is a git work tree with a clean tracked state — the guard runs before any write, so a rejected run leaves nothing behind — and untracked files are allowed. Rejected alongside the non-writing flags (--dry-run/--preview,--check,--report); combines with--interactive, committing each accepted file (including a handeditin$EDITOR) in review order. Git runs throughexecFilewith an argument list and no shell, so a path with a space or a shell metacharacter is committed verbatim. The closing summary reports how many files were committed. The flow lives in a newcommitterdomain, kept out of the library barrel likeprompter/reporter/writer. -
Phase-9 conversion ground-truth fixtures (
fixtures/convert/): one before→ target pair per conversion class, asserted bysrc/__tests__/repo-fixtures.test.tsto checkconvert(input) === target, that the target is valid TSDoc, and that the target is idempotent. The target is authored independently as the correct TSDoc, so a rule that regresses to being consistently wrong fails the test — which a self-snapshot could not catch. Committed fixtures are synthetic because the repo is public; the reference-repo figure (64 of 80 files byte-identical toconvert) is recorded as a locally-reproducible baseline infixtures/README.md. -
convert --interactive/scaffold --interactive(-i) review each changed file one at a time — showing its diff and prompting accept · skip · edit · quit, where edit opens the proposal in$VISUAL/$EDITORand writes back what you save. The closing summary counts only the files actually written, so a run where you skip half reports half. The flag needs a TTY and is rejected up front alongside the non-writing flags (--dry-run/--preview,--check,--report). The flow lives in a newprompterdomain: a pure orchestrator (runInteractive) with the terminal prompt and the editor launcher injected, so accept/skip/edit/quit is tested without a real terminal. -
Project foundation: TypeScript (strict), ESLint flat config with TSDoc dogfooding, Vitest, and unbuild bundling to a
dist/cli.mjsESM binary. -
parserdomain: JSDoc → TSDoc tag registry and a lightweight comment parser. -
transformerdomain: deterministic rule pipeline that converts existing JSDoc syntax to TSDoc (type-brace stripping, tag renames, redundant-tag removal, and the@returns Promise<T>/ optional-bracket /@modulefixes learned from real-world migrations). -
scannerdomain: TypeScript-Compiler-API comment extraction and project file discovery. -
generatordomain: project layout detection (ESLint flat config,tsconfig, package manager, installed deps), custom-tag classification against the TSDoc standard,tsdoc.jsongeneration/merging, and idempotent ESLint flat-config patching. -
initCLI subcommand: bootstrapstsdoc.jsonand the ESLint TSDoc rules (progressivewarnby default,--strictforerror), reports the dev dependencies to install (--installruns the package manager), and supports--dry-rundiffs and--report(json/md). -
convertandscanCLI subcommands with--dry-run/--previewdiffs. -
scannerexport inventory: enumerates exported declarations through the TypeScript Compiler API, classifies each one (React component, Server Action, hook, interface, type alias, class, enum, function, variable), records the stub insertion point and indentation, flags declarations that are already documented, and skips re-export statements. -
scaffolderdomain: deterministic summary inference from identifier names (verb conjugation, predicate phrasing, acronym / kebab / snake splitting) and per-kind TSDoc stub rendering. Every stub carries aTODO(tsdoc)marker so generated prose can be reviewed with a single grep. -
scaffoldCLI subcommand: generates TSDoc stubs for exports that have no documentation, with--dry-run/--previewdiffs,--check(exit3for CI),--only/--excludeglobs, and--report(json/md) including a per-kind breakdown. Generated stubs are valid undertsdoc/syntax, satisfytsdoc-require-2/require, and re-running the command is a no-op. A contract suite runs those two rules over the scaffolded output of every supported export form, so the guarantee is checked against the real linter rather than against an assumption about it. -
escalatordomain: a preflight lint check and a severity patch for the presence rule. The preflight resolves and runs the project’s own ESLint with the project’s own config, collecting everytsdoc-require-2/requiremessage whatever severity it carries — reading the real config instead of forcing the rule on through an override is what keeps the verdict equal to what CI will report,offoverrides included. The patch rewrites only enabled assignments, so an explicitoff(the__tests__/exemptioninitwrites) is never switched on and therequire-param/require-returnssiblings are never touched. -
validatordomain: doc-comment validation against the official@microsoft/tsdocparser — the same parsereslint-plugin-tsdocruns, so a clean result predicts a clean lint. The project’stsdoc.jsonis loaded and applied first, without which every custom tag (@sinceabove all) would be reported as undefined; a missingtsdoc.jsonis treated as “no config”, not as an error, since that is the normal state beforeinitruns. -
checkCLI subcommand: the CI gate, and the only command that validates rather than transforms. Reports invalid TSDoc syntax, exports with no documentation, and comments still holding JSDoc thatconvertwould rewrite; exits3on problems and2whentsdoc.jsonexists but cannot be read. Supports--syntax-only,--include-tests,--only/--exclude, and--report(json/md). Test paths are skipped by default because the ESLint configinitgenerates disables both TSDoc rules for them — the globs are now a single shared constant so the two cannot drift. The CLI gates its own source with this command (npm run check:tsdoc). -
escalateCLI subcommand: the fourth migration step, bumpingtsdoc-require-2/requirefromwarntoerroronce the preflight is clean. Refuses to escalate (exit3) while undocumented exports remain, listing them; supports--dry-run/--previewdiffs,--check(a cheap CI gate that exits3when the repo is not locked in yet, without running ESLint),--severityto walk an escalation back towarn,--skip-preflight, and--report(json/md). -
classifierdomain: classifies every export asvalid,partial,line-comments,no-docsorstale, and aggregates to one verdict plus a confidence level per file. Stale detection is conservative by design — a destructured parameter has no name in the source, so@param titleonfunction Card({ title }: CardProps)cannot be told apart from a stale tag, and parameter staleness is not judged for those signatures rather than guessed at. A report that flags accurate documentation gets ignored, taking its true findings with it. Reachable from the package root alongside every other analysis domain; a test now pins that surface, so a domain can no longer ship with a barrel but no way for a library consumer to reach it. -
scan --classify: documentation topology report with the recommended action per bucket. A file lands in the most severe topology among its exports, since that is the one naming the next step. Files exporting nothing are counted apart from valid ones, and test paths are skipped by default (ascheckalready does) because the ESLint configinitwrites disables both TSDoc rules for them;--include-testsopts in.--report=jsoncarries the full per-declaration detail.--litenarrows only the conversion inventory, so passing it alongside--classifywarns on stderr instead of being dropped in silence. -
scan --fail-on-missing/scan --fail-on-stale: CI gates that exit3on undocumented exports or on documentation that contradicts its signature. Both imply--classify. They live onscanrather thancheck, which already exits3for undocumented exports. -
convertfences an@examplebody when leaving it bare would break TSDoc parsing. An unfenced{is read as the start of an inline tag and its}as the end of one, so a comment that reads perfectly failscheck; the same applies to<,>, and an@anywhere, including inside a word. Only a body containing one of those is touched — a body of plain calls and URLs is left as written, as is any body that already contains a fence, and a hazard inside an inline code span is not treated as one because TSDoc reads a code span literally. Measured against a hand migration of a real repository, this reproduces the human’s fencing decision on 101 of 102 examples and removes 95 of that repository’s 143 remaining errors. -
convertbackticks a bare@that appears mid-line in prose so TSDoc stops reading it as a tag — a TypeScript path alias (@/lib/thing), a scoped package (@scope/pkg), an address, or a decorator named in a sentence. The tag that opens a line is left untouched (it is a real block tag by position, even a project custom), as is anything already inside a code span or a fenced block, and a standard or known-custom tag name mid-prose. Measured against the same hand migration, this clears the remaining bare-@class in full, taking the repository from 48 errors after fencing down to 30. -
convertfolds a dotted@param parent.childinto its parent parameter. TSDoc has no dotted-path form and rejects the name, so a converted comment that reads perfectly failscheck. Rather than drop the child documentation, each child is folded into the parent’s description as a lossless(child: description, …)list — matching the hand migration’s decision for small parameter objects and honoring the same no-data-loss principle as the@propertyrelocation. Handles wrapped child descriptions, several parameter objects in one comment, deeper nesting, and JSDoc array-element syntax. Measured against the same hand migration, this clears the dotted-@paramclass in full, taking the repository from 30 errors down to 4 (the 4 that remain are bare@paramtags with no description that a comment-only tool could recover). -
convert --promote-line-commentsrewrites a run of//prose above an undocumented export as the/** */comment it was already serving as. Without it,scaffoldinserts an inferred stub between that prose and the declaration it explains, so the file gains a worse summary than the one already there. The words are carried across unchanged — nothing is recapitalized or repunctuated — and the result goes through the conversion rules, so a promoted comment is not something the next run rewrites again. Three runs are left alone: one holding a tooling directive, which stops working inside a block comment; one holding a*/, which would close the comment early; and one with no prose in it, because the empty/** */it would produce satisfies the presence rule and would stopcheckreporting the export as undocumented. Off by default.convertreports how many runs it promoted. -
@propertydescriptions are relocated instead of deleted. TSDoc documents a member with its own comment and has no@propertytag, so the tag still goes — but the prose it carries now lands somewhere. It moves onto the member when the member has no comment of its own, is dropped as redundant when the member already documents itself, and becomes a Markdown list item in the original comment when the declaration has no such member (an exported array literal, for instance), which keepsconvertoutput valid forcheck.convertreports how many descriptions it moved. -
Rule.applyreceives theRuleContext. Every rule until now decided from the comment alone;@propertycannot, because whether deleting it loses prose depends on the declaration below the comment, which the pipeline never sees.RuleContext.removablePropertiescarries that decision per comment, and omitting it removes nothing — a caller that cannot prove a deletion is safe must not have that treated as proof it is. -
scanner:collectMemberTargetsmaps each doc comment to the interface or type-literal members of the declaration it sits on, with each member’s documentation state, insertion offset and indentation. -
parser:readPropertyTagsreads each line-leading@propertytag with its description and the exact lines it occupies, folding wrapped descriptions.
Fixed
-
convertno longer leaves a description-less@typeParambehind, which TSDoc rejects (tsdoc-param-tag-missing-hyphen) exactly as it does a hyphenless@param. A JSDoc@template Twith no description renamed to a bare@typeParam Tand failedcheck; a newdrop-bare-type-paramrule removes the tag instead — it documents nothing a reader cannot see in the declaration’s<T>clause, so no prose is lost. A bare@paramis deliberately left forcheckto surface, since a value parameter’s name is the only record the author meant to document it. The rule is--lite-safe, so the minimal@param/@returnspass keeps its output valid too. -
scaffoldno longer emits@param thisfor a function declaring an explicitthistype annotation. TypeScript models it as a parameter, so stubs were written into real source documenting an argument callers never pass, andcheckaccepted them because the syntax is legal TSDoc. -
convertno longer emits a duplicate@packageDocumentationwhen a comment carries more than one file-level tag. JSDoc routinely pairs them —@fileoverviewfor the prose,@modulefor the name — and each was translated independently, so the rewritten comment declared the modifier twice. At most one is emitted now, the tag is not added at all when the comment already has it, and prose from the dropped tags is kept as a summary line so no documentation is lost with them.