create-cs-object 0.1.1 → 0.1.2

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
@@ -16,8 +16,12 @@ Existing destinations are never overwritten.
16
16
 
17
17
  Use `--skip-install` to create files without installing dependencies.
18
18
  The generated project contains a synthetic rectangle calculation, an editable
19
- brief, reference storage, and agent-neutral authoring guidance. The CLI serves
20
- the browser and report API. The initializer contains no server implementation.
19
+ brief, reference storage, and agent-neutral authoring guidance. Its editable
20
+ React and TypeScript page uses Vite, Tailwind and the selected shadcn preset.
21
+ `npm run dev` starts that page and the CLI calculation and report API on localhost.
22
+ The initializer contains no calculation server implementation.
23
+ The page's navbar report menu reads `reports.json`; each configured report runs
24
+ through its own local CLI process.
21
25
 
22
26
  Package tests execute a real npm archive in a temporary consumer. Full runtime
23
27
  acceptance belongs to the repository's installed integration checks.
package/dist/cli.js CHANGED
@@ -37,9 +37,36 @@ try {
37
37
  engines: { node: ">=24" },
38
38
  scripts: {
39
39
  setup: "npm install && node scripts/setup.ts",
40
- dev: "node scripts/dev.ts"
40
+ dev: "node scripts/dev.ts",
41
+ build: "tsc -b && vite build",
42
+ typecheck: "tsc -b --pretty false"
41
43
  },
42
- dependencies: { "@cs-object/cli": "0.1.0" }
44
+ dependencies: {
45
+ "@cs-object/cli": "0.1.1",
46
+ "@cs-object/core": "0.1.0",
47
+ "@base-ui/react": "^1.8.0",
48
+ "@fontsource-variable/geist-mono": "^5.3.0",
49
+ "@fontsource-variable/raleway": "^5.3.0",
50
+ "@hugeicons/core-free-icons": "^4.3.5",
51
+ "@hugeicons/react": "^1.1.10",
52
+ "@tailwindcss/vite": "^4",
53
+ "class-variance-authority": "^0.7.1",
54
+ cn: "^0.4.0",
55
+ react: "^19.2.8",
56
+ "react-dom": "^19.2.8",
57
+ shadcn: "^4.21.0",
58
+ tailwindcss: "^4",
59
+ "tw-animate-css": "^1.4.0",
60
+ vite: "^8",
61
+ zod: "^4.6.5"
62
+ },
63
+ devDependencies: {
64
+ "@types/node": "^24",
65
+ "@types/react": "^19",
66
+ "@types/react-dom": "^19",
67
+ "@vitejs/plugin-react": "^6",
68
+ typescript: "^5"
69
+ }
43
70
  },
44
71
  null,
45
72
  2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cs-object",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Create a local CalculationSourceObject report project.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -30,9 +30,12 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@biomejs/biome": "2.5.14",
33
+ "@cs-object/core": "0.1.0",
33
34
  "@types/node": "^26",
34
35
  "tsup": "^8.5.1",
35
- "typescript": "^5"
36
+ "typescript": "^5",
37
+ "vite": "^8.3.1",
38
+ "zod": "^4.6.5"
36
39
  },
37
40
  "repository": {
38
41
  "type": "git",
@@ -6,6 +6,12 @@ the result, units, load case, or acceptance criteria. Record the agreed scope
6
6
  in the brief and cite the source of each engineering requirement.
7
7
 
8
8
  Edit formulas in `calculations/report.cso.py`; the browser edits numeric inputs.
9
+ Edit page layout in `src/App.tsx`, theme tokens in `src/index.css`, and shared UI
10
+ components in `src/components/ui/`. Keep numeric input names and validation
11
+ derived from the Python definition. Add shadcn components with
12
+ `npx shadcn@latest add <component>`.
13
+ Add each runnable report to `reports.json` with a unique ID, title, source path
14
+ and entry function. Restart the local server to update the report menu.
9
15
  Read [authoring.md](authoring.md) before changing source or notation. Keep inputs,
10
16
  units, assumptions, intermediate steps, references, and outputs inspectable.
11
17
 
@@ -13,6 +13,22 @@ browser, calculate, review the report, and download its PDF. Change formulas in
13
13
  inputs, reload the browser page to rebuild its form. Existing report downloads
14
14
  remain tied to their captured run until that run expires or the server stops.
15
15
 
16
+ Customize the page in `src/App.tsx` and its theme in `src/index.css`. The
17
+ components in `src/components/ui/` are owned by this project. To add another
18
+ shadcn component, run `npx shadcn@latest add <component>` and edit the page to
19
+ use it. `components.json` records the chosen Vite, Base UI and Lyra preset.
20
+ The input fields still come from the Python definition; page edits do not change
21
+ the calculation or API validation. `npm run build` checks and builds the Vite
22
+ frontend, but the local API and PDF routes still require `npm run dev`.
23
+
24
+ The navbar menu lists runnable reports from [reports.json](reports.json).
25
+ To add one, put its `.cso.py` source under `calculations/` and add an entry with
26
+ a unique URL-safe `id`, a display `title`, the relative `source` path and its
27
+ entry `function`. Add `reference` when the report has an independent numerical
28
+ reference file. Restart `npm run dev` after editing the list. Each entry gets
29
+ its own verified local API at `/api/reports/<id>/`; the menu switches between
30
+ them without changing the input or report UI code.
31
+
16
32
  To choose a port, run `npm run dev -- --port 4173`. Use port `0` to select an
17
33
  available port. Stop the server with Ctrl+C and use the same command to restart.
18
34
 
@@ -1,18 +1,179 @@
1
- # Write scalar calculations
2
-
3
- Use constrained Python in `calculations/report.cso.py`. Declare documented inputs
4
- and assignments with `Annotated[float, symbol(...)]` or
5
- `Annotated[int, symbol(...)]`. A plain `float` or `int` parameter needs a separate
6
- documented `given()` assignment.
7
- Use descriptive names and qualified glyphs such as `A_{rect}`. Define units and
8
- explain each glyph qualifier. Return a dictionary that selects documented values.
9
-
10
- Use finite scalar numbers and arithmetic `+`, `-`, `*`, `/`, and `**`.
11
- Python booleans are not numeric inputs. Declare `float` for fractional results.
12
- Exact integers must fit within ±(2**53 - 1). Keep assumptions and references in
13
- literal `text(id=..., content=...)` calls so they appear in the report.
14
-
15
- The [maintained authoring guide](https://github.com/viktar-b/CalculationSourceObject/blob/main/docs/authoring.md)
16
- describes supported functions, composition, and document content. Unsupported
17
- Python constructs must be replaced with supported scalar expressions before
18
- the calculation can pass verification.
1
+ # Author calculations
2
+
3
+ This guide ships with the generated project. Write constrained Python in
4
+ `calculations/*.cso.py`; the installed `cs-object` package captures the same
5
+ source for numerical execution and the report. Start with
6
+ [`calculations/report.cso.py`](calculations/report.cso.py), then replace the
7
+ rectangle example with the calculation in [brief.md](brief.md). Keep the
8
+ brief, sources and reviewable report consistent.
9
+
10
+ ## Before writing formulas
11
+
12
+ State the purpose, input ranges, units, assumptions, required outputs and
13
+ acceptance cases in [brief.md](brief.md). Keep source documents or links under
14
+ [`references/`](references/README.md), with the edition, page or clause and the
15
+ assumption each supports. Ask for a decision when a missing assumption would
16
+ change the result. Establish expected numerical values independently of the
17
+ calculation being checked; formula agreement alone does not establish
18
+ engineering correctness.
19
+
20
+ ## Define a report
21
+
22
+ The starter shows the full pattern:
23
+
24
+ ```python
25
+ from typing import Annotated
26
+ from cso_python import calculation, section, symbol, text
27
+
28
+
29
+ @calculation(id="rectangle-area", title="Rectangle area")
30
+ @section(title="Rectangle", root=True)
31
+ def calculate(
32
+ width: Annotated[float, symbol(glyph="w_{rect}", description="Rectangle width", unit="m")] = 2.0,
33
+ height: Annotated[float, symbol(glyph="h_{rect}", description="Rectangle height", unit="m")] = 3.0,
34
+ ):
35
+ text(id="assumptions", content="Assume perpendicular sides and positive dimensions. The qualifier rect means rectangle.")
36
+ text(id="reference", content="Area equals width multiplied by height; see brief.md for the project scope.")
37
+ area: Annotated[float, symbol(glyph="A_{rect}", description="Rectangle area", unit="m^2")] = width * height
38
+ return {"area": area}
39
+ ```
40
+
41
+ Each runnable function needs `@calculation`, `@section`, documented numeric
42
+ parameters and a final dictionary of public results. Put arithmetic in
43
+ annotated assignments before the return. The return selects existing
44
+ quantities; it does not replace their documented formula rows. Source order
45
+ sets report order, while return order sets the public output order. Intermediate
46
+ annotated quantities remain in the report even when they are not returned.
47
+
48
+ Use descriptive Python names such as `total_area`, not one-letter identifiers.
49
+ Give every quantity a glyph, description and unit. Qualify variable glyphs,
50
+ including diagram labels: `A_{rect}`, `w_{pan}` and `\rho_{mat}` distinguish
51
+ their meanings. Explain qualifiers in the report. Standard unit symbols such as
52
+ `m` and `kg` need no qualifier. Use raw strings for backslash Greek names,
53
+ for example `r"\rho_{mat}"`. The notation parser is not a LaTeX engine:
54
+ `\mathrm` and `\frac` are unsupported. When reproducing an identified
55
+ external calculation, its original glyphs may be retained with a recorded
56
+ source URL and explanation of any repeated notation.
57
+
58
+ Signature metadata creates an input row, including when the parameter is not
59
+ used in a formula. A plain `float` or `int` parameter instead needs one
60
+ annotated `given(parameter)` assignment; do not document the same parameter
61
+ both ways. Give inputs finite literal defaults. Declare `float` when division
62
+ or another operation can produce a fractional value. An `int` declaration
63
+ requires an actual Python integer; a `float` declaration accepts finite
64
+ Python integers and floats. Exact integers must fit within ±(2**53 - 1);
65
+ finite Python floats may use the binary64 range. Booleans are not numeric
66
+ inputs or results. Execution preserves the actual numeric kind and does not
67
+ coerce values to satisfy an annotation.
68
+
69
+ ## Supported formulas and content
70
+
71
+ Supported expressions include finite numeric literals, earlier documented
72
+ quantities, unary minus, `+`, `-`, `*`, `/`, `**`, `sqrt`, `math.pi`, `ceil`,
73
+ `floor`, `exp`, `log`, `abs`, `min`, `max`, `round`, trigonometric functions,
74
+ `atan2` and `hypot`. Import math functions from `math` or call them through
75
+ `math`. Trigonometric functions use radians; declare angle units explicitly.
76
+ `log(value)` is natural logarithm. `round` follows Python's ties-to-even
77
+ behavior for represented binary64 values. Invalid domains and non-finite
78
+ results fail verification.
79
+
80
+ Numeric conditional expressions can use `<`, `<=`, `>`, `>=`, `==`, `!=`,
81
+ comparison chains and `and` / `or` between comparisons. Only the chosen
82
+ numeric branch executes. Bare numeric conditions, boolean quantities,
83
+ `value or fallback`, iterable or keyword forms of `min` / `max` / `hypot`,
84
+ and arbitrary Python statements are outside the supported source subset.
85
+ Run the calculation to detect unsupported syntax; a valid Python expression
86
+ is not necessarily a supported documented formula. This is trusted local
87
+ authoring, not a sandbox for untrusted Python.
88
+
89
+ Place literal `text(id=..., content=...)` calls beside the formulas they
90
+ explain. Document assumptions, limits and reference clauses in the report,
91
+ not only in comments. `figure(...)` accepts a module-relative PNG, JPEG or
92
+ SVG path, caption and alt text. Keep the asset inside the calculation
93
+ directory, for example `calculations/assets/section.svg`:
94
+
95
+ ```python
96
+ from cso_python import figure
97
+
98
+ figure(
99
+ id="section-diagram",
100
+ path="assets/section.svg",
101
+ media_type="image/svg+xml",
102
+ caption="Section dimensions and axes",
103
+ alt="Dimensioned section with x and y axes",
104
+ )
105
+ ```
106
+
107
+ Missing or invalid assets fail instead of silently disappearing. A
108
+ `with document_section(id="...", title="..."):` block can group one level
109
+ of symbols, text and figures. Nested groups, calculation calls and returns
110
+ do not belong inside that block.
111
+
112
+ ## Reuse calculations
113
+
114
+ Keep common `Annotated` metadata or `TypeAlias` declarations in a local
115
+ Python module, then use the same alias for a quantity passed between
116
+ calculations. Generate typed handles before importing another `.cso.py`
117
+ function:
118
+
119
+ ```sh
120
+ ./.venv/bin/python -m cso_python bindings calculations
121
+ ./.venv/bin/python -m cso_python bindings calculations --check
122
+ ```
123
+
124
+ For a file `calculations/geometry.cso.py` with a public function
125
+ `rectangle`, a parent in that directory can import and call it:
126
+
127
+ ```python
128
+ from _cso_bindings.geometry import rectangle
129
+
130
+ first_panel = rectangle(width=width, height=first_panel_height)
131
+ second_panel = rectangle(width=width, height=second_panel_height)
132
+ total_area: TotalPanelArea = first_panel["area"] + second_panel["area"]
133
+ ```
134
+
135
+ `TotalPanelArea` must be declared in the shared metadata module. Calls take
136
+ named arguments. Each call adds a child section at its source location;
137
+ documented intermediate results stay visible even if the child does not
138
+ select them as public outputs. Forwarded quantities retain their identity,
139
+ description, glyph and unit. Callee units must match exactly; write an
140
+ explicit annotated conversion when needed. Equal numeric values do not make
141
+ two quantities identical.
142
+
143
+ Generate bindings from a directory containing the parent and its local
144
+ dependencies. Source paths below it must use Python identifiers, such as
145
+ `steel_sections/calculate.cso.py`. Imports follow those paths. The generated
146
+ `_cso_bindings/` directory must exist at runtime; do not edit its files.
147
+ Regenerate after changing parameters, numeric declarations, defaults,
148
+ metadata or public output selections. Formula-only edits may leave the
149
+ interface current. Generation validates definitions but does not execute
150
+ formulas. A missing or stale handle will not regenerate itself.
151
+
152
+ Each distinct quantity needs distinct displayed notation. Repeated child
153
+ calls qualify child glyphs using their call names. When a reference
154
+ deliberately reuses a glyph in separate contexts, set a meaningful
155
+ `notation_scope` in `symbol(...)` and explain that scope in a description or
156
+ section title. Collisions within one scope fail.
157
+
158
+ ## Check the result
159
+
160
+ Add each top-level runnable function to [reports.json](reports.json) with its
161
+ ID, title, source path and function name, then restart `npm run dev`. The
162
+ browser derives editable inputs from the Python definition and calls the
163
+ verified local API. Recalculate with representative and boundary inputs.
164
+ Review every input, unit, intermediate formula, explanation, figure and
165
+ returned result in the report. Check that the displayed substitutions and
166
+ outputs match the intended engineering method.
167
+
168
+ Keep three judgments separate: source-to-document consistency, agreement
169
+ with independently established numerical cases, and human engineering
170
+ approval. A pending reference check is not a passing reference check.
171
+ Formula or text edits change captured source identity and can invalidate
172
+ reference bindings; review and explicitly rebind them while preserving
173
+ independently established expected values. Never obtain expected values by
174
+ copying the execution being tested.
175
+
176
+ Download and inspect every PDF page at normal size for missing steps,
177
+ unreadable notation, clipped content and pagination. The browser and PDF
178
+ need the same run and inputs. See [README.md](README.md) for local setup,
179
+ report registration and UI customization.
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "base-lyra",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/index.css",
9
+ "baseColor": "taupe",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "hugeicons",
14
+ "rtl": false,
15
+ "aliases": {
16
+ "components": "@/components",
17
+ "utils": "@/lib/utils",
18
+ "ui": "@/components/ui",
19
+ "lib": "@/lib",
20
+ "hooks": "@/hooks"
21
+ },
22
+ "menuColor": "default-translucent",
23
+ "menuAccent": "subtle",
24
+ "registries": {}
25
+ }
@@ -1,4 +1,5 @@
1
1
  node_modules/
2
+ dist/
2
3
  .venv/
3
4
  __pycache__/
4
5
  _cso_bindings/
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Calculation report</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "id": "rectangle-area",
4
+ "title": "Rectangle area",
5
+ "source": "calculations/report.cso.py",
6
+ "function": "calculate"
7
+ }
8
+ ]
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { dirname, join } from 'node:path';
@@ -10,48 +10,119 @@ const python = join(
10
10
  '.venv',
11
11
  process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python',
12
12
  );
13
- try {
13
+
14
+ function awaitOrigin(child: ChildProcess): Promise<string> {
15
+ return new Promise((resolve, reject) => {
16
+ let stdout = '';
17
+ const finish = (error?: Error, origin?: string) => {
18
+ clearTimeout(timeout);
19
+ child.off('error', onError);
20
+ child.off('exit', onExit);
21
+ child.stdout?.off('data', onData);
22
+ if (error) reject(error);
23
+ else if (origin) resolve(origin);
24
+ };
25
+ const onError = (error: Error) => finish(error);
26
+ const onExit = (code: number | null) =>
27
+ finish(new Error(`Calculation server exited (${code})`));
28
+ const onData = (chunk: string) => {
29
+ stdout += chunk;
30
+ const match = /CSO dev (http:\/\/127\.0\.0\.1:\d+)/.exec(stdout);
31
+ if (match?.[1]) finish(undefined, match[1]);
32
+ };
33
+ const timeout = setTimeout(
34
+ () => finish(new Error('Calculation server did not start')),
35
+ 30_000,
36
+ );
37
+ child.once('error', onError);
38
+ child.once('exit', onExit);
39
+ child.stdout?.setEncoding('utf8');
40
+ child.stdout?.on('data', onData);
41
+ });
42
+ }
43
+
44
+ async function main() {
14
45
  if (!existsSync(python))
15
46
  throw new Error(
16
47
  'Run npm run setup to prepare the project Python environment.',
17
48
  );
49
+ const { loadReports } = await import('./reports.ts');
50
+ const args = process.argv.slice(2);
51
+ if (args.length !== 0 && (args.length !== 2 || args[0] !== '--port'))
52
+ throw new Error('Usage: npm run dev -- [--port 0-65535]');
53
+ const spelling = args[1] ?? '5173';
54
+ if (!/^\d+$/.test(spelling) || Number(spelling) > 65535)
55
+ throw new Error('--port must be an integer between 0 and 65535');
56
+ const port = Number(spelling);
57
+ const reports = loadReports(project);
18
58
  const require = createRequire(import.meta.url);
19
59
  const cli = join(
20
60
  dirname(require.resolve('@cs-object/cli/package.json')),
21
61
  'dist/cli.js',
22
62
  );
23
- const child = spawn(
24
- process.execPath,
25
- [
26
- cli,
27
- 'dev',
28
- 'calculations/report.cso.py',
29
- '--function',
30
- 'calculate',
31
- ...process.argv.slice(2),
32
- ],
33
- {
34
- cwd: project,
35
- env: { ...process.env, PYTHON: python },
36
- stdio: 'inherit',
37
- },
38
- );
39
- const interrupt = () => child.kill('SIGINT');
40
- const terminate = () => child.kill('SIGTERM');
41
- process.on('SIGINT', interrupt);
42
- process.on('SIGTERM', terminate);
43
- child.once('error', (error) => {
44
- process.stderr.write(`${error.message}\n`);
45
- });
46
- child.once('close', (code, signal) => {
47
- process.off('SIGINT', interrupt);
48
- process.off('SIGTERM', terminate);
49
- process.exitCode =
50
- code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1);
51
- });
52
- } catch (error) {
63
+ const children: ChildProcess[] = [];
64
+ let vite: Awaited<ReturnType<typeof import('vite').createServer>> | undefined;
65
+ let stopping = false;
66
+ const stop = () => {
67
+ if (stopping) return;
68
+ stopping = true;
69
+ for (const child of children) child.kill('SIGTERM');
70
+ if (vite) void vite.close();
71
+ };
72
+ process.on('SIGINT', stop);
73
+ process.on('SIGTERM', stop);
74
+ try {
75
+ const backends = [];
76
+ for (const report of reports) {
77
+ const child = spawn(
78
+ process.execPath,
79
+ [
80
+ cli,
81
+ 'dev',
82
+ report.source,
83
+ '--function',
84
+ report.function,
85
+ ...(report.reference ? ['--reference', report.reference] : []),
86
+ '--port',
87
+ '0',
88
+ ],
89
+ {
90
+ cwd: project,
91
+ env: { ...process.env, PYTHON: python },
92
+ stdio: ['ignore', 'pipe', 'inherit'],
93
+ },
94
+ );
95
+ children.push(child);
96
+ const origin = await awaitOrigin(child);
97
+ child.once('exit', (code) => {
98
+ if (stopping) return;
99
+ process.stderr.write(
100
+ `Calculation server for ${report.id} exited (${code}).\n`,
101
+ );
102
+ process.exitCode = code || 1;
103
+ stop();
104
+ });
105
+ backends.push({ id: report.id, title: report.title, origin });
106
+ }
107
+ process.env.CSO_REPORT_BACKENDS = JSON.stringify(backends);
108
+ const { createServer } = await import('vite');
109
+ vite = await createServer({
110
+ server: { host: '127.0.0.1', port, strictPort: port !== 0 },
111
+ });
112
+ await vite.listen();
113
+ const address = vite.httpServer?.address();
114
+ if (!address || typeof address === 'string')
115
+ throw new Error('Vite has no localhost address');
116
+ process.stdout.write(`CSO dev http://127.0.0.1:${address.port}\n`);
117
+ } catch (error) {
118
+ stop();
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ main().catch((error: unknown) => {
53
124
  process.stderr.write(
54
125
  `${error instanceof Error ? error.message : String(error)}\n`,
55
126
  );
56
127
  process.exitCode = 1;
57
- }
128
+ });
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PythonIdentifierSchema } from '@cs-object/core';
4
+ import { z } from 'zod';
5
+
6
+ const ReportSchema = z.strictObject({
7
+ id: z.string().regex(/^[a-z][a-z0-9-]*$/),
8
+ title: z.string().min(1),
9
+ source: z.string().regex(/^calculations\/(?!.*\.\.\/)[^/].*\.cso\.py$/),
10
+ function: PythonIdentifierSchema,
11
+ reference: z.string().optional(),
12
+ });
13
+
14
+ export type Report = z.infer<typeof ReportSchema>;
15
+
16
+ export function loadReports(project: string): Report[] {
17
+ const reports = z
18
+ .array(ReportSchema)
19
+ .min(1)
20
+ .parse(JSON.parse(readFileSync(join(project, 'reports.json'), 'utf8')));
21
+ if (new Set(reports.map(({ id }) => id)).size !== reports.length)
22
+ throw new Error('reports.json contains duplicate report IDs');
23
+ return reports;
24
+ }