dsh-hs-classify — Commodity classification register hierarchy consistency check
dsh-hs-classify reads one classification register — the declarant header plus one row per item — and checks the structure of the numbers it records: that a commodity code is ten digits, that its chapter, heading and subheading are successive prefixes of the code, that the levels get progressively finer, that each row records a classification basis, that the header declares the tariff version the codes belong to, that item numbers are unique within the register, and that no unreplaced placeholder survives in the item name.
What it looks like

Real output from this plugin over its own HC-001 test fixture — not a mock-up. The rule pack ships no invented quotations, so a finding names both the clause it applied and the fact that the clause text was not obtained.
What it answers
| You ask | What it answers |
|---|---|
| Some codes in our register are eight digits, and one ends with a letter. Will the check report them? | Yes. HC-001 matches every filled hsCode against the pack's pattern, ^[0-9]{10}$, and reports the row together with the value it read. It checks the digit count and the characters only, never whether the goods belong under that code. A blank code cell is passed over, so what it reports is a code of the wrong shape, not a code that is missing. The ten digits are a pack setting: when the annual tariff changes the length, edit the pattern, not the code. |
| A row's 章, 品目 and 子目 do not agree with its 商品编号. Is that caught? | Yes. HC-002 reads the level columns in coarse-to-fine order — 章, 品目, 子目, two, four and six digits in the pack's own example — and reports the level that is not a prefix of the hsCode it read, or that is not finer than the level above it. It checks that prefix relation and that order only: whether the code fits the goods is a customs determination needing the tariff and the classification decisions, which this plugin does not consult. A level cell left blank is not compared. |
| One row leaves 归类依据 blank. | HC-003 reports that row: basis has to be filled in on every row that carries the column. It checks that something is written there, not that the tariff clause, chapter note, classification decision or advance ruling cited exists or supports the classification — this plugin consults none of them. If the register has no basis column at all, HC-003 is listed in skipped with the reason that the material has no such column, instead of passing silently. |
| The header does not say which tariff version the codes belong to. | HC-004 requires the header to declare tariffVersion and reports the register when the header leaves it empty. It checks that the declaration is present, not that the edition named is the one these codes come from: the rule does not read the codes, and codes are split and merged between editions. Its fields list is a pack setting — add declarant if your header records one. |
| 项号 5 appears on two rows. | HC-005 reports the later row as a duplicate of the earlier one, because itemNo has to be unique within one register; whitespace is ignored in the comparison, so 5 and 5 count as the same item number. It checks uniqueness only. One goods item declared on several rows, for different specifications say, needs a different item number on each row. Item numbers that are all distinct still leave HC-005 in skipped: it is then listed with the reason that the material met the check's preconditions and no differing entries were found — a different reason from the one a rule gives when it could not run because the column is absent. |
| The 品名 column still holds 【】 or 待填 from the template. | HC-006 reports the row and the placeholder it matched. The pack looks for 【, 】, {{, }}, XXX, xxx, 待填, 待补充, TBD, todo and 示例, and terms can be trimmed to your own template. It finds unreplaced placeholders only: it does not judge whether the name is right, and it does not require the column to be filled — a blank description cell draws no finding from it, and no other rule in this pack requires one. |
Standards it follows
| Document | Number | Cited by rules |
|---|---|---|
| 《中华人民共和国进出口税则》 | 现行版本本次未核实 | HC-001, HC-004, HC-005, HC-006 |
| 《商品名称及编码协调制度》 | 现行版本本次未核实 | HC-002 |
| 《中华人民共和国进出口关税条例》 | 国务院令第392号(2003年11月23日公布;根据2011年1月8日、2013年12月7日、2016年2月6日三次《国务院关于修改(废止)部分行政法规的决定》修订) | HC-003 |
Boundary: this plugin checks a 商品归类台账 for the structure of the numbers it records — that a commodity code is ten digits, that its chapter, heading and subheading are successive prefixes of it, that the levels get progressively finer, that a classification basis is recorded, that the tariff version is declared, that item numbers are unique, and that no placeholder survives. It does not decide which code goods should be classified under.
⚠️ It checks the shape of a code, never whether the code is right
Classification is a customs determination, turning on the goods' material, function and degree of processing together with the tariff's section and chapter notes and any classification decisions or advance rulings. This plugin does not consult the tariff, does not consult classification decisions, and does not consult rulings. So:
- it can find "this row's code disagrees with the chapter, heading and subheading the same row states";
- it cannot find "this row's code does not match the goods", because that needs the tariff itself.
A register with a structurally perfect code for the wrong goods passes this plugin. That is the documented limit, stated in the header, in
HC-002's note, and in the troubleshooting section.The rule pack states its citation status rule by rule. 1 of its 6 rules quote verbatim clause text and are marked
direct; the remaining 5 state in theexcerptfield itself that the text was not obtained, and stay atwarnorinfo. Where a rule still carries that note, treat it as a lead rather than as a citation. The regime lives in 《中华人民共和国进出口税则》— whose codes are ten digits, the first six being the WCO Harmonized System — and 《中华人民共和国进出口关税条例》. For the rules whose text the verification pass could not retrieve, the pack states the gap in theexcerptfield itself rather than paraphrasing it, and those rules stay atwarnorinfo. When the texts are in hand, replace eachexcerptwith the real clause and raisekindtodirect. The ten-digit assumption is apatternin the rule pack, so an annual tariff change needs a rule-pack edit, not a code change.
Compatibility
| Surface | Status |
|---|---|
| Harness | Peer range >=0.1.2-rc.1 <0.2.0 || >=0.2.0-0 <0.3.0 — verified to accept both 0.2.0-rc.2 and 0.2.1-alpha.1. engines.dsh is deliberately not declared: it has no reader and cannot reject a host |
| Node | `^22.19.0 |
| Platforms | All (plain ESM; no native code, no network, no model call) |
| Tool mode | Works in native, ptc and both; for a customs declaration's item list use ptc |
What it does
Registers the hs_classify tool. It reads one classification register — the declarant header plus one row per
item — applies a versioned rule pack, and returns a report.
| Rule | Check | Severity | Basis kind |
|---|---|---|---|
HC-001 |
the commodity code is ten digits | warn | principle |
HC-002 |
chapter, heading and subheading are successive prefixes | warn | principle |
HC-003 |
a classification basis is recorded | warn | direct |
HC-004 |
the tariff version is declared | warn | principle |
HC-005 |
item numbers are unique | warn | principle |
HC-006 |
the description holds no unreplaced placeholder | warn | principle |
Install
dsh plugin --profile <name> add dsh-hs-classify
dsh --profile <name> --dump-config | grep 'dsh-hs-classify'
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
rulesFile |
string | rules/hs-classify.yaml |
Rule-pack path, relative to the package root |
disabledRules |
string[] | [] |
Rule ids to stop running; each appears in skipped |
onlyRules |
string[] | [] |
Run only these rule ids; empty runs every rule |
skipNotes |
string | "" |
Note appended to every skipped reason |
timeoutMs |
number | 120000 |
Cooperative tool timeout budget |
Rule-level parameters worth knowing:
HC-001pattern— the code's shape, ten digits by default. Change it when the tariff changes.HC-002field/components/digits— the code column, the level columns in coarse-to-fine order ([chapter, heading, subheading]), and the expected total length.HC-004fields— header fields that must be present; the tariff version by default. Adddeclarantif your register records one.HC-006terms— the placeholders to look for.
Material format
The tool accepts JSON or YAML:
declarant: 某某报关行
tariffVersion: 2026 年版税则
rows:
- { 序号: '1', 品名: 便携式自动数据处理设备, 材质: 塑料外壳、金属结构件、电子元器件,
功能: 数据处理与显示,重量 1.2 千克, 章: '84', 品目: '8471', 子目: '847130',
商品编号: '8471300000', 申报日期: 2026-03-10,
归类依据: 税则第八十四章章注及品目 8471 条文 }
Column names are matched case-insensitively and ignoring spaces, underscores and hyphens; the register's own column names are kept, so a finding names the column it read.
Rule sources
Rule data lives in rules/hs-classify.yaml. The pack's header states what the plugin does and does not judge,
and each rule's note repeats the part that matters for that rule. The load-time guard that normally enforces
"an excerpt must be a real quotation of at least eight characters" cannot tell a quotation from a description —
so this pack leans on the header, the per-rule notes and a test that asserts every excerpt admits the gap.
Troubleshooting
- It did not flag a code I know is wrong for the goods. It cannot: it checks the code's internal structure, not its fit to the goods. Classification needs the tariff and the notes.
HC-002fires although the code looks right. One of the level columns disagrees with the code's leading digits. The finding names the level and shows both values.HC-001fires on a valid code from last year's tariff. Digits can change with the annual tariff. Correct the code, or adjust thepatternif this year's tariff really differs.HC-004reports itself as skipped. The header carries no tariff version. Which tariff a code belongs to matters, because codes are split and merged between editions.- The plugin installs but the tool never appears. Check that
mainresolves tolib/index.mjsand thatpnpm run buildproduced it; a wrongmainmakes the loader skip the entry silently. dsh plugin addrefuses the package as incompatible. The peer range covers0.1.xand0.2.x; if your runtime sits outside it, grant an explicit exemption:dsh plugin --profile <name> allow-version dsh-hs-classify@0.1.0 --dsh-version <runtime> --accept-riskcheckreportsmanifest-peersas failed. The static checker compares against a hard-coded peer range that predates the 0.2 line. The runtime enforces peer compatibility at install time, so the declared range is the correct one; this is a known upstream issue indsh-plugin-dev.
Development
pnpm install
pnpm run typecheck # tsc --noEmit
pnpm test # vitest, the shared table-plugin suite plus paired fixtures
pnpm run build # tsdown -> lib/index.mjs + lib/index.d.mts
node ../scripts/sync-shared.mjs dsh-hs-classify # refresh src/shared from ../_shared
The plugin is data-only: src/model.ts declares the table shape, the shared kit supplies the reader and
the check engine, and the rule pack declares every check.
License
Apache License 2.0 © 2026 dsh-hs-classify contributors.
No comments yet. Be the first to write one.