bsdata-parser 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,55 +1,167 @@
1
1
  # bsdata-parser
2
2
 
3
- A faithful parser for [BSData](https://github.com/BSData) XML into an intermediate representation
4
- (IR), plus a golden-diff harness for validating a downstream projection of that IR against a
5
- reference output.
3
+ A faithful, typed parser for [BSData](https://github.com/BSData) `.gst`/`.cat` XML game-data files into a typed Intermediate Representation (IR), plus a golden-diff harness for validating a downstream projection against a reference output.
6
4
 
7
- The parser exists to replace a **lossy** flatten: a naive parser drops conditions in the source it
8
- cannot statically resolve, which is the root cause of recurring "missing option" bugs in the
9
- downstream consumer. The rebuild keeps those conditions in the IR and resolves them in the projection.
5
+ [![CI](https://github.com/aodhan-dev/bsdata-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/aodhan-dev/bsdata-parser/actions/workflows/ci.yml)
6
+ [![npm](https://img.shields.io/npm/v/bsdata-parser)](https://www.npmjs.com/package/bsdata-parser)
10
7
 
11
- ## Design
8
+ ## Why
9
+
10
+ Naive BSData parsers flatten the XML as they go, silently dropping modifiers and conditions they cannot statically resolve. The result is recurring "missing option" bugs whenever BSData's conditional logic kicks in. This library solves that by keeping the full modifier/condition tree in the IR and leaving resolution to the projection layer.
11
+
12
+ ## Architecture
12
13
 
13
14
  ```
14
- BSData (.gst/.cat) ──parse──▶ IR (faithful tree) ──project──▶ output
15
-
16
- reference output ◀──diff─────┘ (oracle)
15
+ BSData (.gst/.cat) ──parse──▶ IR (faithful tree) ──project──▶ your output
16
+
17
+ reference output ◀───diff───────┘
17
18
  ```
18
19
 
19
- - **`src/parse`** XML IR. Mechanical transcription, domain-agnostic, no interpretation. *(stub)*
20
- - **`src/project`** IR output. All domain interpretation lives here. Supplied locally. *(stub)*
21
- - **`src/index.ts`** `buildCatalogue(files)`, signature-compatible with the downstream consumer
22
- for incremental integration.
23
- - **`harness/`** — the golden diff. `loadOracle()`/`loadBsdata()` read from a local data directory;
24
- `diffCatalogues()` asserts the candidate reproduces the reference as a superset.
20
+ - **`src/parse`** - XML to IR. Mechanical transcription only: no interpretation, no flattening, no domain knowledge. Every modifier, condition, constraint, and repeat node is preserved verbatim.
21
+ - **`src/project`** - IR to output. All domain interpretation lives here. This layer is intentionally not included in the package - you supply it, shaped to your own output schema.
22
+ - **`harness/`** - golden-diff utilities. Load a reference output (oracle) and diff a candidate against it to find gaps. Auto-skips when no local data is present, so the test suite runs cleanly everywhere.
25
23
 
26
- ## Data (local only, never committed)
24
+ ## Installation
27
25
 
28
- No data is bundled with this repo. The BSData source files and the reference output are third-party
29
- content you assemble yourself. Point the `BSDATA_DIR` environment variable at a local directory
30
- containing:
26
+ ```bash
27
+ npm install bsdata-parser
28
+ ```
31
29
 
32
- - `bsdata/` — the `.cat`/`.gst` source set (input to the parser)
33
- - `catalogue.json` — a reference output (the oracle to diff against)
30
+ ## Usage
34
31
 
35
- Everything under that directory stays outside the repo. The concrete domain projector you plug into
36
- `src/project` is likewise kept local (gitignored), so nothing data- or domain-specific is published.
32
+ ```typescript
33
+ import { parseToIr } from 'bsdata-parser'
34
+ import type { Ir } from 'bsdata-parser'
37
35
 
38
- ## Commands
36
+ // 1. Load your .gst/.cat files however you like (fs, fetch, etc.)
37
+ const files: Record<string, string> = {
38
+ 'my-game.gst': '<gameSystem ...>...</gameSystem>',
39
+ 'faction-a.cat': '<catalogue ...>...</catalogue>',
40
+ }
41
+
42
+ // 2. Parse to IR - faithful, lossless, typed
43
+ const ir: Ir = parseToIr(files)
44
+
45
+ // 3. Project to your output schema
46
+ const output = myProjector(ir)
47
+ ```
48
+
49
+ `parseToIr` accepts a `filename -> XML string` map and returns an `Ir` object containing the fully parsed game system and all catalogues as typed trees.
50
+
51
+ ## IR shape
52
+
53
+ ```typescript
54
+ interface Ir {
55
+ gameSystem: IrCatalogueFile // the .gst file
56
+ catalogues: IrCatalogueFile[] // all .cat files
57
+ }
58
+
59
+ interface IrCatalogueFile {
60
+ filename: string
61
+ kind: 'gameSystem' | 'catalogue'
62
+ id: string
63
+ name: string
64
+ gameSystemId?: string
65
+ catalogueLinks: IrCatalogueLink[]
66
+ root: IrCatalogueRoot
67
+ }
68
+
69
+ interface IrCatalogueRoot {
70
+ costTypes: IrCostType[]
71
+ profileTypes: IrProfileType[]
72
+ categoryEntries: IrCategoryEntry[]
73
+ rules: IrRule[]
74
+ sharedRules: IrRule[]
75
+ sharedProfiles: IrProfile[]
76
+ selectionEntries: IrSelectionEntry[]
77
+ sharedSelectionEntries: IrSelectionEntry[]
78
+ sharedSelectionEntryGroups: IrSelectionEntryGroup[]
79
+ entryLinks: IrEntryLink[]
80
+ forceEntries: IrForceEntry[]
81
+ }
82
+ ```
83
+
84
+ All node types (`IrSelectionEntry`, `IrConstraint`, `IrModifier`, `IrCondition`, etc.) are exported from the package. See `src/ir/types.ts` for the complete set.
85
+
86
+ ## Writing a projector
87
+
88
+ A projector is a function `(ir: Ir) => YourOutputType`. It lives in your own codebase and is never part of this package:
39
89
 
40
- - `npm test` — unit suite plus the golden-parity test (auto-skips unless `BSDATA_DIR` is set,
41
- so it runs cleanly anywhere, including CI with no data).
42
- - `npm run golden` run the golden diff against the local reference and print the gap.
43
- - `npm run build` — build a local output from the local source with the parser.
44
- - `npm run typecheck` — `tsc --noEmit`.
90
+ ```typescript
91
+ import { parseToIr } from 'bsdata-parser'
92
+ import type { Ir } from 'bsdata-parser'
45
93
 
46
- ## Status
94
+ function projectMyGame(ir: Ir): MyOutput {
95
+ const categories: Record<string, string> = {}
96
+ for (const entry of ir.gameSystem.root.categoryEntries) {
97
+ categories[entry.id] = entry.name
98
+ }
99
+
100
+ const units = ir.catalogues.flatMap(cat =>
101
+ cat.root.sharedSelectionEntries
102
+ .filter(e => e.type === 'unit' && !e.hidden)
103
+ .map(e => ({ id: e.id, name: e.name, faction: cat.name }))
104
+ )
105
+
106
+ return { categories, units }
107
+ }
108
+
109
+ export function buildOutput(files: Record<string, string>): MyOutput {
110
+ return projectMyGame(parseToIr(files))
111
+ }
112
+ ```
113
+
114
+ ## Golden-diff harness
115
+
116
+ The `harness/` directory contains utilities for validating that your projector reproduces a known-good reference output as a superset. Copy or adapt these into your own project:
117
+
118
+ - `harness/oracle.ts` - `loadBsdata()`, `loadOracle()`, `hasData()` (reads from `BSDATA_DIR`)
119
+ - `harness/diff.ts` - `diffCatalogues(oracle, candidate)` - counts-based diff, domain-agnostic
120
+
121
+ Set `BSDATA_DIR` to a local directory containing:
122
+ - `bsdata/` - the `.cat`/`.gst` source files
123
+ - `catalogue.json` - a reference output to diff against
124
+
125
+ ```typescript
126
+ // your-project/harness/golden-parity.test.ts
127
+ import { describe, it, expect } from 'vitest'
128
+ import { readFile, readdir } from 'node:fs/promises'
129
+ import { existsSync } from 'node:fs'
130
+ import { join } from 'node:path'
131
+ import { buildOutput } from './my-projector'
132
+
133
+ const dir = process.env.BSDATA_DIR ?? ''
134
+ const hasData = () => existsSync(join(dir, 'catalogue.json'))
135
+
136
+ describe.skipIf(!hasData())('golden parity', () => {
137
+ it('reproduces every oracle collection', async () => {
138
+ const oracle = JSON.parse(await readFile(join(dir, 'catalogue.json'), 'utf-8'))
139
+ const names = (await readdir(join(dir, 'bsdata'))).filter(f => f.endsWith('.cat') || f.endsWith('.gst'))
140
+ const files: Record<string, string> = {}
141
+ await Promise.all(names.map(async n => { files[n] = await readFile(join(dir, 'bsdata', n), 'utf-8') }))
142
+ const candidate = buildOutput(files)
143
+ // diff: for every key in oracle, compare collection size
144
+ const diffs = Object.keys(oracle).filter(k => {
145
+ const size = (v: unknown) => Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v as object).length : 0
146
+ return size(oracle[k]) !== size((candidate as Record<string, unknown>)[k])
147
+ })
148
+ expect(diffs).toEqual([])
149
+ })
150
+ })
151
+ ```
152
+
153
+ ## Data and domain code (never committed)
154
+
155
+ No game data is bundled here. BSData source files are third-party content you assemble yourself. Your projector is likewise kept local - nothing data- or domain-specific should be published.
156
+
157
+ ## Commands
47
158
 
48
- Scaffold. `parseToIr`/`projectCatalogue` are stubs, so the golden-parity test is **red by design** —
49
- that is the baseline the rebuild is built green against, test-first.
159
+ | Command | Description |
160
+ |---------|-------------|
161
+ | `npm test` | Unit suite + golden-parity test (auto-skips without `BSDATA_DIR`) |
162
+ | `npm run build` | Compile to `dist/` via tsup |
163
+ | `npm run typecheck` | `tsc --noEmit` |
50
164
 
51
- ## Method
165
+ ## License
52
166
 
53
- TDD against the BSData XML as the source of truth: establish the expected value from the `.cat`/`.gst`
54
- before writing the assertion. The golden diff is the integration oracle; per-entity unit tests are
55
- the inner loop.
167
+ MIT
package/dist/index.d.ts CHANGED
@@ -1,9 +1,257 @@
1
+ /**
2
+ * BSData intermediate representation (IR).
3
+ *
4
+ * A FAITHFUL, lossless-by-intent tree mirror of the BSData .gst / .cat model.
5
+ * Nothing is resolved, flattened, or interpreted here. Scope strings are raw BSData
6
+ * values. Modifier/condition trees are opaque. All interpretation belongs in the
7
+ * projection (src/project).
8
+ */
9
+ interface IrConstraint {
10
+ id: string;
11
+ type: 'min' | 'max';
12
+ value: number;
13
+ scope: string;
14
+ field: string;
15
+ shared?: boolean;
16
+ includeChildSelections?: boolean;
17
+ }
18
+ interface IrCharacteristic {
19
+ name: string;
20
+ typeId: string;
21
+ value: string;
22
+ }
23
+ interface IrProfile {
24
+ id: string;
25
+ name: string;
26
+ hidden: boolean;
27
+ typeId: string;
28
+ typeName: string;
29
+ characteristics: IrCharacteristic[];
30
+ }
31
+ interface IrCost {
32
+ name: string;
33
+ typeId: string;
34
+ value: number;
35
+ }
36
+ interface IrRule {
37
+ id: string;
38
+ name: string;
39
+ hidden: boolean;
40
+ description: string;
41
+ }
42
+ interface IrInfoLink {
43
+ id: string;
44
+ name: string;
45
+ hidden: boolean;
46
+ targetId: string;
47
+ type: string;
48
+ }
49
+ interface IrCategoryLink {
50
+ id: string;
51
+ targetId: string;
52
+ primary: boolean;
53
+ }
54
+ /** Verbatim condition node - not evaluated, retained for the modifier tree. */
55
+ interface IrCondition {
56
+ type: string;
57
+ value: number;
58
+ field: string;
59
+ scope: string;
60
+ childId?: string;
61
+ shared?: boolean;
62
+ includeChildSelections?: boolean;
63
+ percentValue?: boolean;
64
+ }
65
+ interface IrConditionGroup {
66
+ type: string;
67
+ conditions: IrCondition[];
68
+ conditionGroups: IrConditionGroup[];
69
+ }
70
+ /** Verbatim modifier node - not evaluated. */
71
+ interface IrModifier {
72
+ type: string;
73
+ field: string;
74
+ value: string | number;
75
+ conditions: IrCondition[];
76
+ conditionGroups: IrConditionGroup[];
77
+ repeats: IrRepeat[];
78
+ }
79
+ /**
80
+ * A BSData modifierGroup node: a block of modifiers that share a common set of conditions.
81
+ * Equivalent to a conditional modifier block - the conditions guard when ALL the inner modifiers apply.
82
+ */
83
+ interface IrModifierGroup {
84
+ type: string;
85
+ modifiers: IrModifier[];
86
+ conditions: IrCondition[];
87
+ conditionGroups: IrConditionGroup[];
88
+ modifierGroups: IrModifierGroup[];
89
+ }
90
+ interface IrRepeat {
91
+ value: number;
92
+ repeats: number;
93
+ field: string;
94
+ scope: string;
95
+ childId?: string;
96
+ shared?: boolean;
97
+ includeChildSelections?: boolean;
98
+ roundUp?: boolean;
99
+ }
100
+ type IrEntryType = 'unit' | 'model' | 'upgrade' | 'mount';
101
+ interface IrSelectionEntry {
102
+ id: string;
103
+ name: string;
104
+ hidden: boolean;
105
+ collective: boolean;
106
+ import: boolean;
107
+ type: IrEntryType;
108
+ defaultAmount?: number;
109
+ constraints: IrConstraint[];
110
+ profiles: IrProfile[];
111
+ rules: IrRule[];
112
+ infoLinks: IrInfoLink[];
113
+ categoryLinks: IrCategoryLink[];
114
+ costs: IrCost[];
115
+ modifiers: IrModifier[];
116
+ modifierGroups?: IrModifierGroup[];
117
+ selectionEntries: IrSelectionEntry[];
118
+ selectionEntryGroups: IrSelectionEntryGroup[];
119
+ entryLinks: IrEntryLink[];
120
+ }
121
+ interface IrSelectionEntryGroup {
122
+ id: string;
123
+ name: string;
124
+ hidden: boolean;
125
+ collective: boolean;
126
+ import: boolean;
127
+ defaultSelectionEntryId?: string;
128
+ constraints: IrConstraint[];
129
+ modifiers: IrModifier[];
130
+ modifierGroups?: IrModifierGroup[];
131
+ selectionEntries: IrSelectionEntry[];
132
+ selectionEntryGroups: IrSelectionEntryGroup[];
133
+ entryLinks: IrEntryLink[];
134
+ }
135
+ interface IrEntryLink {
136
+ id: string;
137
+ name: string;
138
+ hidden: boolean;
139
+ collective: boolean;
140
+ import: boolean;
141
+ flatten: boolean;
142
+ targetId: string;
143
+ type: string;
144
+ defaultAmount?: number;
145
+ comment?: string;
146
+ constraints: IrConstraint[];
147
+ costs: IrCost[];
148
+ modifiers: IrModifier[];
149
+ modifierGroups?: IrModifierGroup[];
150
+ profiles: IrProfile[];
151
+ infoLinks: IrInfoLink[];
152
+ categoryLinks: IrCategoryLink[];
153
+ selectionEntries: IrSelectionEntry[];
154
+ selectionEntryGroups: IrSelectionEntryGroup[];
155
+ entryLinks: IrEntryLink[];
156
+ }
157
+ interface IrCategoryEntry {
158
+ id: string;
159
+ name: string;
160
+ hidden: boolean;
161
+ }
162
+ interface IrForceCategoryLink {
163
+ id: string;
164
+ targetId: string;
165
+ primary: boolean;
166
+ hidden: boolean;
167
+ constraints: IrConstraint[];
168
+ }
169
+ interface IrForceEntry {
170
+ id: string;
171
+ name: string;
172
+ hidden: boolean;
173
+ constraints: IrConstraint[];
174
+ categoryLinks: IrForceCategoryLink[];
175
+ rules: IrRule[];
176
+ modifiers: IrModifier[];
177
+ forceEntries: IrForceEntry[];
178
+ }
179
+ interface IrCostType {
180
+ id: string;
181
+ name: string;
182
+ defaultCostLimit: number;
183
+ }
184
+ /** A BSData catalogueLink node: references another catalogue this one imports from. */
185
+ interface IrCatalogueLink {
186
+ id: string;
187
+ name: string;
188
+ targetId: string;
189
+ type: string;
190
+ importRootEntries: boolean;
191
+ }
192
+ interface IrProfileType {
193
+ id: string;
194
+ name: string;
195
+ characteristicTypes: Array<{
196
+ id: string;
197
+ name: string;
198
+ }>;
199
+ }
200
+ interface IrCatalogueRoot {
201
+ costTypes: IrCostType[];
202
+ profileTypes: IrProfileType[];
203
+ categoryEntries: IrCategoryEntry[];
204
+ rules: IrRule[];
205
+ /** Rules from the BSData &lt;sharedRules&gt; element: the canonical rule-definition pool. */
206
+ sharedRules: IrRule[];
207
+ /** Profiles from the BSData &lt;sharedProfiles&gt; element: abilities with Summary/Description characteristics. */
208
+ sharedProfiles: IrProfile[];
209
+ selectionEntries: IrSelectionEntry[];
210
+ sharedSelectionEntries: IrSelectionEntry[];
211
+ sharedSelectionEntryGroups: IrSelectionEntryGroup[];
212
+ entryLinks: IrEntryLink[];
213
+ forceEntries: IrForceEntry[];
214
+ }
215
+ /** A single parsed BSData file (game system or catalogue). */
216
+ interface IrCatalogueFile {
217
+ filename: string;
218
+ kind: 'gameSystem' | 'catalogue';
219
+ id: string;
220
+ name: string;
221
+ gameSystemId?: string;
222
+ catalogueLinks: IrCatalogueLink[];
223
+ root: IrCatalogueRoot;
224
+ }
225
+ /** The whole parsed source set: the game system plus every catalogue. */
226
+ interface Ir {
227
+ gameSystem: IrCatalogueFile;
228
+ catalogues: IrCatalogueFile[];
229
+ }
230
+
231
+ declare function parseToIr(files: Record<string, string>): Ir;
232
+
233
+ /**
234
+ * IR -> downstream projection.
235
+ *
236
+ * Projects the faithful IR into the flattened, ready-to-consume shape the downstream application
237
+ * needs. ALL domain interpretation lives here; the IR and parser stay domain-agnostic. The concrete
238
+ * projector and its reference data are supplied locally and are never committed to this repo (see
239
+ * README) -- this repo ships the framework, not any specific data model.
240
+ *
241
+ * "Superset" is the contract: the projection MUST reproduce every field the reference output carries
242
+ * (so the existing consumer keeps working unchanged), and MAY add fields the current consumer lacks.
243
+ * The golden-diff harness asserts the reproduction half; new fields are additive and ignored.
244
+ *
245
+ * STUB. Supply a local implementation that imports your projector modules. The local file is
246
+ * gitignored so it never reaches this public repo. See CLAUDE.md for the hygiene rule.
247
+ */
248
+ declare function projectCatalogue(_ir: Ir): Record<string, unknown>;
249
+
1
250
  /**
2
251
  * Public entry point. `filename -> XML` map in, projected output object out. The signature matches
3
252
  * the downstream consumer's existing build step so this can be wired in incrementally (swap the
4
253
  * import, keep the golden diff green) without touching the consumer.
5
254
  */
6
- export declare function buildCatalogue(files: Record<string, string>): Record<string, unknown>;
7
- export { parseToIr } from './parse/index';
8
- export { projectCatalogue } from './project/catalogue';
9
- export type { Ir, IrCatalogueFile, IrModifierGroup } from './ir/types';
255
+ declare function buildCatalogue(files: Record<string, string>): Record<string, unknown>;
256
+
257
+ export { type Ir, type IrCatalogueFile, type IrModifierGroup, buildCatalogue, parseToIr, projectCatalogue };