--dashboard's ai reporter prints the same one-line health summary as
--health --reporters ai, not the per-file breakdown (that only renders
on console, the default) — so the skill's opening example was
technically valid but misleading about what it actually returns.
Use --health --reporters ai for the compact prioritization signal, and
point at plain --dashboard (or --dashboard --reporters json) for the
full picture.
A third installable agent skill, broader than dry-refactoring's
duplication-only scope: find and fix duplicated code (delegates to
dry-refactoring), then find and remove or refactor dead code
(--dead-code), then find and simplify the largest/most complex files
(--complexity) — prioritized from --health/--dashboard's three
sub-scores and re-measured at the end to confirm the pass helped.
Also fills a gap the new skill's own cross-reference exposed: the
jscpd skill's option table never mentioned --dead-code at all despite
listing --complexity and --dashboard/--health. Added it and its
related flags, and cross-linked both new-workflow directions from the
jscpd skill's closing section. README.md and docs/ai-ready.md's skill
registries updated to list all three.
basta now has a proper release: github.com/kucherenko/basta got its
own signed GitHub release with binaries, checksums, provenance and
sigstore signatures (mirroring what this repo's own release does),
built on the release surface its repo was already designed for (a
submodule pin to this workspace + its own v*-tag-triggered workflow)
but had never actually used.
That makes this repo's basta-release.yml redundant — it duplicated
that same publish logic and never created a release with artifacts
anywhere. Remove it; jscpd's own crates-publish.yml is unaffected,
since its basta-backfill logic (publishing basta as a dependency when
a fresh jscpd release needs it on crates.io first) is self-contained
and doesn't depend on basta-release.yml existing.
basta is a workspace member but releases on its own basta-v* tag; a
version bump to it alone still made the crates_needs_publish loop
over crates/*/Cargo.toml return true, which made create-rust-github-
release try to recreate the still-current jscpd release and fail with
"Release.tag_name already exists" (seen pushing the basta 0.1.2 bump
to master before tagging it).
Track a narrower cpd_needs_publish signal (true only when the jscpd
crate itself is unpublished) and gate the GitHub release on that
instead of the broad crates_needs_publish, which stays as-is for
gating the crates.io publish job — that one is supposed to backfill
basta too.
Single-file component support (Vue/Svelte/Astro), bundler alias
resolution (vite.config, svelte.config, tsconfig paths) and monorepo
workspace package-name resolution landed on master after basta 0.1.1
was tagged (#1062); the published crate and npm packages don't have
them yet.
- Console tables (project/duplication-by-format/complexity/dead-code) and
the "largest:" summary line interpolated file paths and format names
raw: the Markdown escaping fix in the previous commit only covered
Markdown, not console. A newline in a path or a custom --formats-exts
name forged an extra table row with none of the other columns' data.
Sanitized at table()'s one choke point and at the "largest:" line.
- format_list() (the --health console/ai duplication line) had the same
gap for the formats it lists, independent of the console table fix.
- --complexity --fail-on-empty returned before the reporter loop ran, so
-r json never wrote a report; the clone-detection and dead-code paths
write reports first and apply the gate to the exit code afterward.
--complexity now does the same.
- dead_code::config()'s early Ok(None) for an unsupported --format
returned before parsing --dead-code-categories or clamping
--min-confidence, so --dashboard --format css --dead-code-categories
bogus silently succeeded despite the comment above it saying that must
stay a refusal. Moved both checks before the format check.
8 new tests.
Two independent reviews of the health/dashboard work landing since v5.2.1,
before cutting 5.3.0.
Scoring:
- Markup lines stayed in the duplication denominator while excluded from
the numerator, so adding one unique, un-duplicated HTML file could swing
a project from grade D to grade B. The denominator now excludes markup
too, and an all-markup project skips the dimension entirely instead of
scoring ~75 from the small-project prior alone.
- is_markup() matched file extensions (plantuml, puml, tpl) instead of the
tokenizer's actual format names (plant-uml, smarty), so those formats
were never really excluded.
- A health-input weight of 1e308 overflowed the weighted geometric mean to
NaN/100 with exit 0; validate() now rejects non-finite or unreasonably
large weights.
- External metric ids could duplicate each other or collide with a
built-in dimension id.
Output safety:
- Markdown output (dimension ids, format names, file paths, dead-code
finding names) was interpolated unescaped: a hostile path or
--health-input id could break a table or inject raw HTML/a heading
(GitHub renders HTML embedded in Markdown), or split console/ai output
via an embedded newline.
CLI:
- A config file with a known field of the wrong JSON type (e.g. "entry":
"src/index.js" where an array is expected) discarded the entire config,
--threshold included, instead of just that field — on any
auto-discovered .jscpd.json, no new flags required.
- --dashboard --format css hard-errored instead of dropping the dead-code
section.
- --dead-code/--complexity didn't conflict with --mcp.
- --complexity silently ignored --threshold, --exit-code,
--fail-on-empty, the baseline family, --history and --kind.
- --dead-code --min-confidence 200 silently reported nothing instead of
clamping to 100, unlike the standalone basta binary.
- --dashboard --max-lines let the dead-code section silently cover files
the other sections skipped (basta needs whole files for its import
graph).
- duplication.percentage/deadCode.percentage in dashboard JSON carried raw
float division noise instead of the two decimals the console already
shows.
26 new tests. Full default-path parity re-verified against the released
5.2.1 binary (13 reporters, option matrix) — byte-identical except timing.
Dependabot opened five separate PRs bumping oxc_syntax, oxc_parser,
oxc_allocator, oxc_semantic and oxc_ast individually (#1066-#1070), but the
oxc crates share AST/Visitor trait definitions across the family and only
compile against matching versions of each other — bumping one alone breaks
the build (Visit trait signature mismatches, missing .span() methods) on
every CI job. oxc_ast_visit and oxc_span also needed the same bump, but
dependabot had not opened PRs for those two.
Bumps all seven together instead, and fixes the one real API change
between 0.147 and 0.150: ParserReturn::panicked was renamed to
::fatal_error (same meaning, confirmed against oxc_parser's source).
cpd-tokenizer's own oxc_parser/oxc_ast pins stay at 0.147, coexisting
fine as a separate resolution in the same Cargo.lock (no basta/
cpd-tokenizer API overlap), so this stays scoped to basta.
display_source_path() returned OS-native separators via to_string_lossy(),
so a summary path (dashboard's complexity/duplication sections) read
"src\route.js" on Windows instead of "src/route.js" everywhere else — the
convention this codebase already follows in mcp.rs, ai.rs and
summary.rs's parent_dir(). Fixes the Windows CI failure in
dashboard_has_the_health_badge_and_a_json_report, pre-existing before
this branch's dashboard/health work.
- Console health badge: reverse-video grade chip, eighth-block gauges per
dimension, one row per sub-score with what it was measured from.
- markdown/html reporters for --dashboard and --health, alongside the
existing console/json/badge; the health SVG badge moves into badge.rs.
- --dashboard's Duplication section lists a breakdown by format instead of
by file, sourced from the same jscpd statistics as everywhere else.
- Duplication no longer counts markup, stylesheet and template clones
(HTML, CSS, Handlebars, ...): a repeating style rule is not the
maintenance problem repeating programming logic is. The exclusion
follows the clone's own format, not the file it lives in, so a .svelte
or .vue file's style/template block is left out even though the
component itself is full weight.
- The duplication line and format-breakdown table now name what they
actually measured and, only for categories the project has files in,
what was left out (e.g. "5.4% in typescript (no text)") instead of a
fixed disclaimer.
One 0-100 score with an A-E grade, shown on top of --dashboard and alone
with --health.
- Dimensions are shares of the code lines, so size cancels out:
duplication (jscpd's percentage over code files), dead code, and the
share of code in complex files (complexity 50+). Prose and data files
are not counted.
- Each share is scored on a half-life curve (100 at zero, 50 at one
half-life), calibrated on 42 open-source projects so the median project
scores 75 per dimension; shares are mixed with a 2000-line prior so one
finding does not sink a small project.
- Sub-scores combine as a weighted geometric mean. A dimension that
cannot be measured is left out and named, and dead code weighs as much
as the share of the code it could read.
- --health-input FILE and the health config object add metrics from
other tools (a ready score, or a value with a half-life) and tune the
built-in half-lives and weights.
- Reporters: console badge, ai one-liner, json (jscpd-health.json,
jscpd-dashboard.json with every dashboard section) and badge
(jscpd-health-badge.svg). Exit gates apply as in the dashboard.
CpdClone::matched_lines moves to cpd-core so statistics and health count
duplicated lines the same way.
- --dashboard applies the exit gates of a clone run (--threshold,
--exit-code, --fail-on-empty) instead of always returning 0; the
baseline family warns and --fail-on-new-clones is refused, since no
clone report is written.
- A refused dead-code configuration (bad --dead-code-categories, a
--format no analyzer supports) now exits 1 as in --dead-code, rather
than printing a dashboard with an empty dead-code section.
- --workers is a budget for the whole dashboard run: the two concurrent
scans get half each, so neither the thread count nor the 64 MiB
tokenizer stacks double. --workers 1 runs them sequentially.
- --complexity scans with similarity disabled, so --similarity no longer
pays for function extraction it cannot use.
- --complexity honours a summaryBy from the config file, not only the
--summary-by flag (Options::summary_by_set).
- reStructuredText joins the prose formats at complexity 0.
`comment_style` names no arm for txt, log or csv, so they fall to the
`_ => CommentStyle::CStyle` fallback, the same way markdown did before
11553327. A glob such as `docs/**` in a text file opens a block comment
that never closes, and every later clone in that file goes unreported;
`https://` turns the rest of its line into a comment.
With jscpd 5.2.1, two .txt files sharing a paragraph report no clone once
one of them holds `--ignore docs/**` above it (412 tokens, 0 clones);
with this change the clone is reported (130 tokens, 1 clone).
--dashboard prints one screen: project size and largest formats,
duplication with clones per kind and the most duplicated files, total and
mean complexity with the most complex files, and dead code by category
with the largest findings (JavaScript, TypeScript, Python). The dead-code
scan runs beside clone detection; --summary-top sets the rows per list.
Data formats (JSON, YAML, TOML, CSV, ...) join markdown at complexity 0,
so a lock file's version ranges no longer top the complexity ranking.
Docs for --kind, --complexity and --dashboard; fixtures/dashboard-demo,
plus --kind and --complexity sections in the type3 and summary demos.
--kind exact,renamed,similar,gap,ast keeps only the clones of those kinds
(config key "kind"). The filter runs before statistics, so percentages
describe what is reported. An unknown kind is an error; a kind whose
detector is off (ast without --similarity, ...) is a warning, and
detection is never switched on implicitly.
--complexity walks and tokenizes like a normal run, skips clone
detection, and prints the --summary tables ranked by complexity without
the DUP% column (console, ai; json writes jscpd-complexity.json).
Markdown prose now has complexity 0: "if" in a README is a word.
Folding the symlink test assertions into a helper put it between
#[cfg(unix)] and symlink_layout, so the attribute moved to the helper and
std::os::unix::fs::symlink no longer compiled on Windows.
b72abf09 also searched the directory of each scanned path. fixtures/ has
a .jscpd.json with "silent": true, so `cd rust && jscpd ../fixtures -r ai`
started printing only the summary line instead of the clones that
`npx jscpd` lists. Config discovery is back to the working directory;
output now matches jscpd 5.2.1 across all reporters.
- FinderError was never constructed; run() cannot fail, so its error type
is Infallible and the documented run(&config).unwrap() keeps compiling.
- tokenize_sfc_block_inner and tokenize_to_detection_inner forwarded to
tokenize_format_to_detection; call it directly.
- extract_line_number was Some(error.line()).
- Timer wrapped Instant; use Instant.
serde, anyhow, thiserror and log from cpd-finder; thiserror from
cpd-reporter; xxhash-rust and dev serde_json from cpd-tokenizer; anyhow,
log and env_logger from cpd. None of them is referenced in code.
`tokenize_line_content`, which cuts every line of every non-JavaScript file
into tokens, was a 218-line loop with a cyclomatic complexity of 49. Each of
its nine token classes repeated the same index, column and byte-offset
bookkeeping and its own ignore-region check.
A `LineCursor` now owns the position, the column and the ignore-region kind,
and each class is one call on it: `take`, `take_while`, `take_string`,
`take_word` or `take_rest`. The loop that dispatches between them is 37 lines
with a complexity of 15. The separate Lua `--[[` branch goes: the line comment
check right after it already matched Lua's `--` and produced the same token.
`comment_kind` is folded into `LineCursor::kind`.
Checked against the previous implementation, copied beside the new one for a
one-off differential test: every text file in `fixtures/` and `rust/` — 839
of them — under all seven comment styles, plus edge cases for unterminated
and escaped strings, block comments across lines, ignore regions, and
multi-byte and non-breaking-space input, produced 5,039,506 tokens identical
in kind, text, line, column and offset. On ten trending projects (7,181 files,
11.2 million tokens) the JSON report is byte-identical, and fifteen
alternating single-worker runs put old and new within noise of each other
(median 4.04 s old, 4.01 s new).
`main` was the most complex function in the workspace: one 437-line body
with a cyclomatic complexity of 84 and thirteen `process::exit` calls,
running config loading, flag validation, detection, baselines, blame,
history, reporter planning, output and exit codes in sequence.
It is now a short dispatch over functions that each do one of those
things: `load_options`, `check_format_names`, `validate_baseline_flags`,
`scan_paths`, `run_config`, `detect_and_report`, `display_paths`,
`apply_baseline`, `blame`, `history`, `plan_reporters`, `run_reporters`,
`print_time_and_tips` and `exit_status`. A run that ends early returns
`Err(Exit(code))`, and only `main` exits the process. The largest function
left has a complexity of 15.
Reporter planning is a pure function now, and has tests. One of them pins
the rule that took care to carry over: a `--reporters silent` run prints no
timing line, but adding `--threshold` adds a reporter that prints.
Behaviour is unchanged. The previous and the new binary agree on stdout,
stderr, exit code and written report across 28 invocations covering --list,
--debug, every warning and validation error, a missing path and config,
--silent and --threshold in combination, unknown reporters, --exit-code
with and without clones, empty scans with and without --fail-on-empty,
--absolute, --summary, --baseline with --update-baseline, and --blame; and
the fixtures corpus reports in every mode are byte-identical.
`jscpd rust/` reported 48 clones. This folds every one that was repetition
rather than a deliberate copy, leaving 34 across `rust/` and 14 in
`rust/crates`, all of the kinds the #1053 pass kept on purpose: fixture
corpora, `use` blocks, the TypeScript file and its JavaScript twin,
`impl Reporter` headers and the `ts_strip` visitor pair.
- basta's JSONC reader copied a string-skipping loop into both `strip_jsonc`
and `drop_trailing_commas`. It is now `strip_comments` followed by a comma
pass over `lang::outside_strings`, the quote-aware scanning the bundler
config readers already share.
- The JavaScript and Python analyzer tests each built their own module
index, import lookup, binding-reference lookup and member-read list. They
live once in `test_scan` as `index_of`, `import`, `binding_reference` and
`member_reads`.
- `test_scan::TempTree` writes a test's files and removes them when dropped,
so a failing assertion no longer leaves its directory behind; the script
and `extends` tests that repeated the setup use it.
- Four tsconfig `paths` tests with one shape are one table-driven test.
- The CLI integration tests share `duplicate_shapes`, `history_stdout` and
`assert_only_the_real_file_was_scanned`.
- `scripts/publish-npm.sh` repeated a 43-line "publish the platform package,
then the main packages under --force-main" tail in two branches, and the
"publish unless already live, or show the dry run" block five times. They
are `publish_platform_target` and `publish_package`. Run against the old
script with the build, the registry check and `npm` stubbed, all eleven
combinations of --target, --all, --force-main, --dry-run, --provenance, a
failed build and missing platforms print the same lines and exit the same.
Behaviour is unchanged: the fixtures corpus reports in every mode, the
summary, the console output and the dead-code demo are byte-identical.
basta reads JavaScript, TypeScript and Python, so `jscpd --dead-code` has no
view of the Rust crates, and the compiler does not warn about an unused `pub`
item. Every crate in the workspace is `publish = false`, though, so a `pub`
item nothing in the workspace uses is dead as surely as a private one. A scan
of every `pub` item's uses across the crates, their tests and the fuzz
targets, checked by hand, found:
- `CloneKind::is_exact`: used nowhere.
- `ConfigResult::has_diagnostics`, `basta::lang::language_of`,
`TokenizeOptions::normalizes` and `TokenizeOptions::with_code_ignore_patterns`:
called only by tests written for them. The language test now asks
`analyzer_for` directly.
- `stats_with_pct` is a reporter test fixture that was compiled into the
library; it moves inside the `#[cfg(test)]` fixtures module that re-exported it.
- Fields kept alive by `#[allow(dead_code)]`: `MarkdownFence::front_matter`
and `SfcBlock::tag` are written and never read, and `Options::list` is never
read because `main` reads `cli.list`.
- `scripts/regression_check.sh` and its 1528-line `tests/golden.json`: a gate
for the Wave 2/3 migration that nothing runs, against a report schema that
has since changed.
Behaviour is unchanged: the JSON reports for the fixtures corpus in mild, weak
and strict modes, with Type-2 normalization, and with the complexity summary,
the console output and the dead-code demo output are byte-identical before and
after.
Copilot's review of #1062 raised seven points; each checked out and each is
fixed with a test that fails without it.
- A glob keeps its pattern. `import.meta.glob('./locales/*.js')` and
import(`./pages/${name}.vue`) used to keep every file under the directory
alive; they now reach what the pattern matches, `*` staying within one
segment as it does for the bundler. On Firefly an image glob,
'../../**/*.{png,jpg}', had been keeping all of src/ alive.
- Every pattern of an `import.meta.glob([...])` array is kept, not the first.
- A declared alias is not second-guessed: when `@` points at src/ and the file
is missing there, the `@/` project-root fallback no longer finds a
different file. A catch-all `*` path claims no prefix and does not count.
- Astro client <script> blocks are read as the modules Astro bundles them
into, so what they import is used. A script with any attribute but `src` is
left to the browser, as Astro leaves it.
- import('./x') in markup is found only inside expressions, not in comments
or plain text.
- A quoted attribute value is an expression only on a directive (`:prop`,
`@click`, `#slot`, `v-*`); elsewhere only its `{...}` interpolations are,
so `class="card"` no longer credits a declaration named `card`.
- An object key in markup, `{ label: value }`, is not a read.
Re-measuring on the thirty trending projects also exposed `?raw` imports, as
in `import script from './panzoom.js?raw'`: a bundler query now is stripped
before resolving. On one snapshot of those projects the previous release
reports 1093 unused files and this branch 481, with the share some import
names falling from 83% to 62%; the CHANGELOG now quotes those numbers
instead of ones measured against a development baseline. Components used
only from .mdx content are still reported, and the docs say so.
The CX column was one path per file plus every branch keyword the token
stream showed, and on the generic tokenizer that list was partly
unreachable: it splits punctuation into single characters, so the && and ||
entries never matched outside JavaScript and ?? counted twice. Measured
against lizard on ten GitHub-trending projects in nine languages, CX ranked
files with a median Spearman correlation of 0.83 and put their size at 0.75
of lizard's.
- Adjacent punctuation is joined before matching, so short-circuit
operators count in every language.
- Per-language rules: Rust match arms count by their =>, less one per match
since N arms are N paths; Swift adds guard and Go select. Where ? also
marks an optional (TypeScript, JavaScript, Swift, Kotlin, Groovy, C#) it
counts only as a ternary, told apart from String? and x?.y by being
written in the open; ?: is Elvis only in Kotlin and Groovy.
- One path per function: by declaring keyword (def, fn, func, function, fun,
and => for arrow functions), and in C, C++, Java, Objective-C and C# by the
head(args) { shape told apart from if (...) {. Languages with neither keep
the per-file baseline.
- The body of a """ or ''' string no longer counts. The line-based tokenizer
leaves its quotes in adjacent literals, and an odd number of triple quotes
in such a run opens or closes the string, which handles the PEP 257
opener """Summary. that a pair-of-tokens check read backwards. This is
limited to languages where """ is a delimiter: where a doubled quote is an
escape (C#, VB.NET, SQL, Pascal) it swallowed a 413-branch C# file.
- A keyword-shaped word the file binds as a name - case = 3, x.case,
f(case, when) - is not a branch in that file.
On the same ten projects the median correlation rose to 0.92 and the ratio
to 1.00, and every project improved. Where CX and lizard still differ they
differ on purpose: lizard counts a match once whatever its arms, and does
not count ??.
Detection, tokenization and clone output are untouched: the fixtures corpus
reports the same 248 clones. fixtures/summary-demo has one file per language
whose complexity can be counted by hand, and every CX value matches it.
basta reported most of a real Vue, Svelte or Astro project as dead: on
thirty GitHub-trending projects in those three ecosystems it flagged 1572
unused files, and some import in the tree contradicted 78% of them. The
misses were almost never in the analysis itself; they were imports only the
project's own build knows how to resolve. This reads them the way the build
does.
- Single-file components are read whole. The script of a .vue, .svelte or
.astro file is masked in place so positions stay real, and the markup is
scanned for the names it reads: <Foo />, kebab-case spellings, {{ }}
interpolations, Svelte actions, attribute expressions, and a literal
import() written in markup.
- Aliases come from vite.config.* resolve.alias and svelte.config.* kit.alias
as well as tsconfig paths, and SvelteKit's $lib is known without the
generated .svelte-kit/tsconfig.json no repository commits. The project's
own declaration now wins over the ~/ @/ project-root fallback.
- A computed import whose static head names a directory,
import(`./pages/${name}.vue`), and import.meta.glob('./locales/*.js') are
expanded into one namespace edge per file there, the way a bundler hands
back whole module objects. Such an import no longer marks its module as
dynamic access, so the module's own findings keep their confidence.
- In a monorepo a package name resolves through that package's package.json,
exports subpath by subpath with source conditions preferred over ./dist,
anywhere in the workspace pnpm-workspace.yaml, lerna.json, turbo.json or a
workspaces field marks out.
- A path literal in a build config resolves against the project root, and
Nitro's api/, routes/ and middleware/ are file-system routes.
On the same thirty projects reported unused files fell from 1572 to 468.
fixtures/dead-code-demo gains components/, bundler/ and monorepo/. On master
the bundler demo reports five files as certainly dead, four of them wrongly;
the monorepo demo reports badge.ts, which the app imports by package name.
Both now report only what is dead. The quote-aware byte scanning the markup
and config readers share lives in one place, lang::Quotes and
lang::outside_strings, so rust/crates keeps master's 25 clones.
Two failures on the branch, neither on macOS, which is why they surfaced
only in CI.
`cargo deny` rejected `unicode_names2`, which `ruff_python_parser` pulls in
and which carries `Unicode-DFS-2016` alongside its MIT/Apache dual licence.
That licence covers the character tables and is the older wording of
`Unicode-3.0`, already on the allow list; added beside it.
The two new config-discovery tests asserted on `../project/.jscpd.json`,
and Windows prints the separator it joined with, so both failed there.
They now normalise backslashes before comparing — the assertion is about
which file was read, not about how the path is spelled.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
`cargo publish` verifies by building the packaged crate against the
*published* versions of its dependencies, not the local paths. basta uses
`cpd_core::deadcode`, `cpd_reporter::deadcode` and a public `LineIndex` —
none of which exist in the versions on crates.io — so every attempt failed
with "failed to verify package tarball", and basta could not reach crates.io
until a jscpd release happened to carry those three crates along.
That is the coupling this workflow exists to avoid. It now walks basta's own
dependency graph, publishes in topological order whatever is missing, and
waits for the index between crates. jscpd is excluded by construction: it is
a sibling, not a dependency, and releasing it is not this workflow's job.
Bumps the three crates that actually changed: cpd-core 0.1.13 -> 0.1.14
(the `deadcode` module), cpd-tokenizer 0.1.15 -> 0.1.16 (`LineIndex` made
public, `.pyi`), cpd-reporter 0.1.14 -> 0.1.15 (the dead-code reporters).
cpd-finder is untouched and stays where it is.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
`basta@0.1.0` published with `files: ["run-basta.js", "platform-map.js"]`,
so npmjs.com/package/basta shows an empty page — the one place most people
will look before installing anything. npm takes the README from the
published tarball, so a version bump is the only way to put one there.
The README is npm-facing rather than a copy of the crate's: it leads with
`npm install -g basta` and `npx basta src`, and it names the known gaps —
no Vue/Svelte/Astro, class members matched by name, PEP 420 namespace
packages — so a reader can judge whether the tool fits before running it.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
Deprecating needs npm credentials, and this project's live in repository
secrets rather than on a laptop. Takes a spec and a message; an empty
message un-deprecates, which is npm's own convention and what makes a
wrong message recoverable.
Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV