From 3eea135e39ba0e4bdbca224b57d8b44b2bb73e06 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 06:10:07 +0000 Subject: [PATCH 01/12] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/lino-objects-codec/issues/39 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index 824bd1a..57150dd 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,2 +1,3 @@ # .gitkeep file auto-generated at 2026-05-10T19:22:27.543Z for PR creation at branch issue-35-03946ff48852 for issue https://github.com/link-foundation/lino-objects-codec/issues/35 -# Updated: 2026-08-20T05:25:16.696Z \ No newline at end of file +# Updated: 2026-08-20T05:25:16.696Z +# Updated: 2026-08-20T06:10:07.182Z \ No newline at end of file From 9d340c503066f9bfb5465a7a14ca58ed9a1927a1 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 06:24:16 +0000 Subject: [PATCH 02/12] feat(js,python,rust): add readable format, opt-in tracing and cross-language compact detection --- .../issue-39/data/ci-runs-recent.json | 1 + docs/case-studies/issue-39/data/issue-37.json | 1 + .../issue-39/data/issue-39-comments.json | 1 + docs/case-studies/issue-39/data/issue-39.json | 1 + docs/case-studies/issue-39/data/pr-38.diff | 1834 +++++++++++++++++ docs/case-studies/issue-39/data/pr-38.json | 1 + docs/case-studies/issue-39/data/pr-40.json | 1 + js/src/codec.js | 184 +- js/src/debug.js | 56 + js/src/readable.js | 548 +++++ .../link_notation_objects_codec/__init__.py | 27 +- .../src/link_notation_objects_codec/codec.py | 197 +- .../src/link_notation_objects_codec/debug.py | 54 + .../link_notation_objects_codec/readable.py | 495 +++++ rust/src/debug.rs | 80 + rust/src/lib.rs | 33 +- rust/src/readable.rs | 2 + 17 files changed, 3491 insertions(+), 25 deletions(-) create mode 100644 docs/case-studies/issue-39/data/ci-runs-recent.json create mode 100644 docs/case-studies/issue-39/data/issue-37.json create mode 100644 docs/case-studies/issue-39/data/issue-39-comments.json create mode 100644 docs/case-studies/issue-39/data/issue-39.json create mode 100644 docs/case-studies/issue-39/data/pr-38.diff create mode 100644 docs/case-studies/issue-39/data/pr-38.json create mode 100644 docs/case-studies/issue-39/data/pr-40.json create mode 100644 js/src/debug.js create mode 100644 js/src/readable.js create mode 100644 python/src/link_notation_objects_codec/debug.py create mode 100644 python/src/link_notation_objects_codec/readable.py create mode 100644 rust/src/debug.rs diff --git a/docs/case-studies/issue-39/data/ci-runs-recent.json b/docs/case-studies/issue-39/data/ci-runs-recent.json new file mode 100644 index 0000000..fdbb7a3 --- /dev/null +++ b/docs/case-studies/issue-39/data/ci-runs-recent.json @@ -0,0 +1 @@ +[{"conclusion":"success","createdAt":"2026-08-20T05:47:14Z","databaseId":32336933162,"event":"push","headBranch":"main","headSha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-08-20T05:42:07Z","databaseId":32336595605,"event":"pull_request","headBranch":"issue-37-0e0bcabcea22","headSha":"29373e36e8aa7cf7122795894ce4cb817061ba01","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-10T19:42:06Z","databaseId":25637974944,"event":"push","headBranch":"main","headSha":"0529f91279da52f6e6b475cb2169b04edbd431ea","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-10T19:38:16Z","databaseId":25637892451,"event":"pull_request","headBranch":"issue-35-03946ff48852","headSha":"a9ad369d371735f6f9194f905e8920044c9c62e4","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356119,"event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356114,"event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"failure","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356076,"event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"failure","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356073,"event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847432,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847426,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847417,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847415,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507413,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507401,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507389,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507388,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405283,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405261,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405242,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"failure","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405237,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053655,"event":"push","headBranch":"main","headSha":"e1303e202fc96b20cae6adfef52ff132d36f7366","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053645,"event":"push","headBranch":"main","headSha":"e1303e202fc96b20cae6adfef52ff132d36f7366","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053638,"event":"push","headBranch":"main","headSha":"e1303e202fc96b20cae6adfef52ff132d36f7366","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384224,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"dc46eb61a843463864d8b7e4ab1178609b7afa12","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384221,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"dc46eb61a843463864d8b7e4ab1178609b7afa12","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384218,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"dc46eb61a843463864d8b7e4ab1178609b7afa12","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279804,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"c78eb96863b3a80c2925ea756f638c8a3c75f58a","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279793,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"c78eb96863b3a80c2925ea756f638c8a3c75f58a","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279785,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"c78eb96863b3a80c2925ea756f638c8a3c75f58a","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485840,"event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485829,"event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485826,"event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485825,"event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881566,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881563,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881551,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881549,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710867,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710859,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710856,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"}] diff --git a/docs/case-studies/issue-39/data/issue-37.json b/docs/case-studies/issue-39/data/issue-37.json new file mode 100644 index 0000000..a864c28 --- /dev/null +++ b/docs/case-studies/issue-39/data/issue-37.json @@ -0,0 +1 @@ +{"author":{"id":"MDQ6VXNlcjE0MzE5MDQ=","is_bot":false,"login":"konard","name":"Konstantin Diachenko"},"body":"## Summary\n\n`encode()` base64-encodes every string and emits the whole object on one line. The result is not human-readable, which defeats the point of Links Notation as \"the portable, human-readable syntax\" and \"the reviewable import/export projection\".\n\nRequested: make an indented, plain-text form the default, and keep the current single-line encoded form available under an explicit name.\n\n## What is produced today\n\nReal stored file from a deployment that persists its state through this crate:\n\n```\n(object ((str dHlwZQ==) (str Um91dGVyU3RhdGU=)) ((str c3VidHlwZQ==) (str VG9rZW5TdG9yZQ==)) ((str dm…\n```\n\nDecoding by hand: `dHlwZQ==` → `type`, `Um91dGVyU3RhdGU=` → `RouterState`, `c3VidHlwZQ==` → `subtype`, `VG9rZW5TdG9yZQ==` → `TokenStore`.\n\nThe structure is sensible, but nobody can see that without running base64 on every field. Reviewing a diff, grepping for a key, or spotting a wrong value are all impossible.\n\nEncoding is unconditional (`src/lib.rs:33`), so ASCII keys like `type` need it as much as text that actually does. `src/lib.rs:14` states the reason:\n\n> **UTF-8 Support**: Full Unicode string support using base64 encoding\n\nUnicode support is a real requirement, but the notation already has a quoting strategy for it, and this crate implements it in `format_indented_value` — single quotes, double quotes, and doubling for embedded quotes.\n\n## The proposed default\n\nOne construct — `( )` — for both objects and arrays, at every level including the root. Indentation is for readability only; what distinguishes the two is the content: `key value` pairs make an object, bare values make an array.\n\n**JSON:**\n\n```json\n{\n \"type\": \"RouterState\",\n \"server\": { \"host\": \"127.0.0.1\", \"port\": 18878 },\n \"models\": [\"claude-haiku\", \"claude-opus\"],\n \"value\": [\n { \"id\": \"7cf7abf6\", \"label\": \"bootstrap-admin\", \"ttl_hours\": 24, \"revoked\": false },\n { \"id\": \"94b36f7e\", \"label\": \"wrapper-run\", \"ttl_hours\": 720, \"revoked\": false }\n ]\n}\n```\n\n**Links Notation:**\n\n```\n(\n type \"RouterState\"\n server (\n host \"127.0.0.1\"\n port 18878\n )\n models (\n \"claude-haiku\"\n \"claude-opus\"\n )\n value (\n (\n id \"7cf7abf6\"\n label \"bootstrap-admin\"\n ttl_hours 24\n revoked false\n )\n (\n id \"94b36f7e\"\n label \"wrapper-run\"\n ttl_hours 720\n revoked false\n )\n )\n)\n```\n\n### Mapping\n\n| JSON | Links Notation |\n|---|---|\n| object, including the root | `( )` containing `key value` pairs, one per line |\n| array | `( )` containing values, one per line |\n| object as an array element | its own `( )` |\n| string | double quotes, no encoding |\n| number, `true`/`false`, `null` | bare, so the type survives the round trip |\n\n### Why parentheses rather than indentation alone for a nested object\n\nBoth parse, but they mean different things. Verified against the parser:\n\n```\nserver\n host \"127.0.0.1\"\n port 18878\n```\n→ `(server (host …))`, `(server (port …))` — two separate facts about `server`.\n\n```\nserver (\n host \"127.0.0.1\"\n port 18878\n)\n```\n→ `server → [(host …), (port …)]` — one object with two fields, which is what the JSON meant.\n\nThe same argument applies to array elements: without their own `( )`, the fields of consecutive records merge into one flat list and the record boundaries cannot be recovered.\n\n## Dependency\n\nThis form relies on parentheses opening a nested indentation context, which landed in [link-foundation/links-notation#283](https://github.com/link-foundation/links-notation/pull/283) and is released as `links-notation` **0.14.0**.\n\nThis crate currently depends on `links-notation` 0.13, where the behaviour is absent: inside `( )`, indentation is ignored and every line collapses into one flat list. So **the dependency has to be raised to 0.14 before the new default can work**.\n\nVerified against the 0.14 parser: the document above parses into exactly the structure described, and\n\n```\nvalue (\n id \"1\"\n label \"one\"\n)\n```\n\nnow yields two links — `(id \"1\")` and `(label \"one\")` — where 0.13 produced four loose references.\n\n## What is being asked\n\n1. Raise the `links-notation` dependency to 0.14, so parentheses open a nested indentation context.\n2. Make the indented, plain-text form above the **default** output of `encode()`.\n3. Keep the current single-line base64 form as a configurable option under an explicit name — `encode_compact()` or `encode_obfuscated()` — so the code and its tests are preserved and callers opt into it deliberately. Any other output styles stay available the same way: configurable, never the default.\n4. Encode a value only when it genuinely cannot be represented as text, and mark those values individually rather than encoding everything.\n5. Accept both forms when decoding, so existing files keep working and migrate on next write.\n6. Update the docs: `src/lib.rs:866` currently teaches the opposite — `// String \"Alice\" is base64-encoded as \"QWxpY2U=\"`.\n\nThe point is the default. A library called without reading its documentation should produce a readable file; today the readable form exists but is reachable only by knowing about a separate module.\n\n## Tests worth adding\n\n- `encode()` of an object with ASCII keys and values produces output containing them verbatim;\n- output spans multiple lines with indentation, not one line;\n- nested objects and arrays round-trip to the same structure;\n- an object used as an array element keeps its boundary;\n- numbers and booleans survive the round trip as numbers and booleans, not strings;\n- a value containing quotes, newlines or non-ASCII text round-trips exactly;\n- a value that cannot be represented as text is marked individually and round-trips;\n- a file written in the previous base64 form still decodes.\n\n## Note\n\nFound while auditing what a downstream project stores on disk. Every fact needed to read the file was present, but only after base64-decoding four fields by hand to discover the structure was correct all along.\n","closedAt":"2026-08-20T05:47:12Z","createdAt":"2026-08-20T03:03:29Z","number":37,"title":"encode() base64-encodes every string on one line, so .lino files are not human-readable — the readable formatter already exists but is not the default","url":"https://github.com/link-foundation/lino-objects-codec/issues/37"} diff --git a/docs/case-studies/issue-39/data/issue-39-comments.json b/docs/case-studies/issue-39/data/issue-39-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/case-studies/issue-39/data/issue-39-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/case-studies/issue-39/data/issue-39.json b/docs/case-studies/issue-39/data/issue-39.json new file mode 100644 index 0000000..6778053 --- /dev/null +++ b/docs/case-studies/issue-39/data/issue-39.json @@ -0,0 +1 @@ +{"author":{"id":"MDQ6VXNlcjE0MzE5MDQ=","is_bot":false,"login":"konard","name":"Konstantin Diachenko"},"body":"https://github.com/link-foundation/lino-objects-codec/pull/38 - here was applied to only Rust, we need to apply it to all languages, and docs.\n\nAlso we need to make sure any changes in code for single language without change in all of them will fail the CI/CD on pull requests.\n\nSo all languages are always updated at the same time.\n\nUse all the best practices from CI/CD templates (check full file tree to compare for all GitHub workflow and CI/CD scripts file), if the same issue is found in template report issue also in templates:\n- https://github.com/link-foundation/js-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/rust-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/python-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template\n\nWe should compare all files, so we don't have more CI/CD errors in the future and reuse all the best practices from these templates.\n\nWe need to download all logs and data related about the issue to this repository, make sure we compile that data to `./docs/case-studies/issue-{id}` folder, and use it to do deep case study analysis (also make sure to search online for additional facts and data), in which we will reconstruct timeline/sequence of events, list of each and all requirements from the issue, find root causes of the each problem, and propose possible solutions and solution plans for each requirement (we should also check known existing components/libraries, that solve similar problem or can help in solutions).\n\nIf there is not enough data to find actual root cause, add debug output and verbose mode if not present, that will allow us to find root cause on next iteration.\n\nIf issue related to any other repository/project, where we can report issues on GitHub, please do so. Each issue must contain reproducible examples, workarounds and suggestions for fix the issue in code. Also double check to fully apply requirements to entire codebase, so if we have issue in multiple places, it should be fixed in all them.\n\nPlease plan and execute everything in this single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done.","createdAt":"2026-08-20T06:09:11Z","labels":[{"id":"LA_kwDOQWrSmc8AAAACP4o4IA","name":"bug","description":"Something isn't working","color":"d73a4a"}],"number":39,"title":"Apply it to all languages and docs","url":"https://github.com/link-foundation/lino-objects-codec/issues/39"} diff --git a/docs/case-studies/issue-39/data/pr-38.diff b/docs/case-studies/issue-39/data/pr-38.diff new file mode 100644 index 0000000..bd138aa --- /dev/null +++ b/docs/case-studies/issue-39/data/pr-38.diff @@ -0,0 +1,1834 @@ +diff --git a/.gitkeep b/.gitkeep +index a6e2948..824bd1a 100644 +--- a/.gitkeep ++++ b/.gitkeep +@@ -1 +1,2 @@ +-# .gitkeep file auto-generated at 2026-05-10T19:22:27.543Z for PR creation at branch issue-35-03946ff48852 for issue https://github.com/link-foundation/lino-objects-codec/issues/35 +\ No newline at end of file ++# .gitkeep file auto-generated at 2026-05-10T19:22:27.543Z for PR creation at branch issue-35-03946ff48852 for issue https://github.com/link-foundation/lino-objects-codec/issues/35 ++# Updated: 2026-08-20T05:25:16.696Z +\ No newline at end of file +diff --git a/README.md b/README.md +index 3a6c9ba..24908d2 100644 +--- a/README.md ++++ b/README.md +@@ -42,6 +42,7 @@ All implementations share the same design philosophy and provide feature parity. + - **Circular References**: Automatically detect and preserve circular references + - **Object Identity**: Maintain object identity for shared references + - **UTF-8 Support**: Full Unicode string support using base64 encoding ++- **Readable by Default (Rust)**: `encode()` writes indented, plain-text Links Notation; the previous single-line base64 form stays available as `encode_compact()` + - **Simple API**: Easy-to-use `encode()` and `decode()` functions + - **JSON/Lino Conversion**: Convert between JSON and Links Notation (JavaScript) + - **Reference Escaping**: Properly escape strings for Links Notation format (JavaScript) +@@ -98,11 +99,25 @@ let data = LinoValue::object([ + ("age", LinoValue::Int(30)), + ("active", LinoValue::Bool(true)), + ]); ++// `encode` produces readable, indented Links Notation + let encoded = encode(&data); ++assert_eq!(encoded, "(\n name \"Alice\"\n age 30\n active true\n)"); ++ + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, data); + ``` + ++```lino ++( ++ name "Alice" ++ age 30 ++ active true ++) ++``` ++ ++The single-line base64 form is still available as `encode_compact()` (alias ++`encode_obfuscated()`), and `decode()` accepts both forms. ++ + ### C# + + ```bash +@@ -354,6 +369,10 @@ The library uses the [links-notation](https://github.com/link-foundation/links-n + + - Basic types are encoded with type markers: `(int 42)`, `(str aGVsbG8=)`, `(bool True)` + - Strings are base64-encoded to handle special characters and newlines ++- **Rust exception**: `encode()` defaults to the readable indented form described in ++ [rust/README.md](rust/README.md), where strings are quoted rather than encoded and ++ only values containing control characters are marked as `(base64 "...")`; the form ++ above is what `encode_compact()` produces + - Collections with self-references use built-in links notation self-reference syntax: + - **Format**: `(obj_id: type content...)` + - **Python example**: `(obj_0: dict ((str c2VsZg==) obj_0))` for `{"self": obj}` +diff --git a/experiments/issue-37/parenthesis-indentation/.gitignore b/experiments/issue-37/parenthesis-indentation/.gitignore +new file mode 100644 +index 0000000..2c96eb1 +--- /dev/null ++++ b/experiments/issue-37/parenthesis-indentation/.gitignore +@@ -0,0 +1,2 @@ ++target/ ++Cargo.lock +diff --git a/experiments/issue-37/parenthesis-indentation/Cargo.toml b/experiments/issue-37/parenthesis-indentation/Cargo.toml +new file mode 100644 +index 0000000..5f426c9 +--- /dev/null ++++ b/experiments/issue-37/parenthesis-indentation/Cargo.toml +@@ -0,0 +1,8 @@ ++[package] ++name = "parenthesis-indentation-probe" ++version = "0.1.0" ++edition = "2021" ++ ++# Switch this between "0.14.0" and "0.13.0" to compare parser behaviour. ++[dependencies] ++links-notation = "0.14.0" +diff --git a/experiments/issue-37/parenthesis-indentation/src/bin/shapes.rs b/experiments/issue-37/parenthesis-indentation/src/bin/shapes.rs +new file mode 100644 +index 0000000..bba9bfe +--- /dev/null ++++ b/experiments/issue-37/parenthesis-indentation/src/bin/shapes.rs +@@ -0,0 +1,25 @@ ++use links_notation::parse_lino_to_links; ++ ++fn show(name: &str, text: &str) { ++ println!("=== {} ===\n{}\n---", name, text); ++ match parse_lino_to_links(text) { ++ Ok(links) => { ++ for l in &links { ++ println!("{:?}", l); ++ } ++ } ++ Err(e) => println!("ERR: {:?}", e), ++ } ++ println!(); ++} ++ ++fn main() { ++ show("doc", "(\n type \"RouterState\"\n server (\n host \"127.0.0.1\"\n port 18878\n )\n models (\n \"claude-haiku\"\n \"claude-opus\"\n )\n)"); ++ show("array of objects", "(\n value (\n (\n id \"1\"\n label \"one\"\n )\n (\n id \"2\"\n label \"two\"\n )\n )\n)"); ++ show("empty link", "(\n a ()\n b ()\n)"); ++ show("scalar root", "42"); ++ show("string root", "(\n \"hello\"\n)"); ++ show("quotes", "(\n a \"say \"\"hi\"\"\"\n b 'it''s'\n)"); ++ show("newline in quotes", "(\n a \"line1\nline2\"\n)"); ++ show("single pair obj", "(\n a 1\n)"); ++} +diff --git a/experiments/issue-37/parenthesis-indentation/src/main.rs b/experiments/issue-37/parenthesis-indentation/src/main.rs +new file mode 100644 +index 0000000..3f9cc07 +--- /dev/null ++++ b/experiments/issue-37/parenthesis-indentation/src/main.rs +@@ -0,0 +1,24 @@ ++//! Experiment for issue #37: does `( )` open a nested indentation context? ++//! ++//! Run with links-notation 0.13 and 0.14 to compare: ++//! cargo run # 0.14 (as pinned in Cargo.toml) ++//! cargo add links-notation@0.13.0 && cargo run ++//! ++//! 0.13 ignores indentation inside `( )` and flattens every line into one list, ++//! so record boundaries and nested objects cannot be recovered. 0.14 keeps them. ++ ++use links_notation::parse_lino_to_links; ++ ++const DOCUMENT: &str = "(\n server (\n host \"127.0.0.1\"\n port 18878\n )\n)"; ++ ++fn main() { ++ println!("input:\n{DOCUMENT}\n"); ++ match parse_lino_to_links(DOCUMENT) { ++ Ok(links) => { ++ for link in &links { ++ println!("parsed: {link:?}"); ++ } ++ } ++ Err(e) => println!("parse error: {e:?}"), ++ } ++} +diff --git a/rust/Cargo.lock b/rust/Cargo.lock +index 459386d..a9d339b 100644 +--- a/rust/Cargo.lock ++++ b/rust/Cargo.lock +@@ -10,13 +10,25 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + + [[package]] + name = "links-notation" +-version = "0.13.0" ++version = "0.14.0" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "e4c952b42a8c6ff6f849d7cafe3b1e13f1063a51bbb144bc6c62026ab327814c" ++checksum = "9b6e5f36d99612ea82da43dbd5efb37b0b4e9c4b8197228e073b60ef5a0e78f2" + dependencies = [ ++ "links-notation-macro", + "nom", + ] + ++[[package]] ++name = "links-notation-macro" ++version = "0.1.0" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "f30ea96250240a92d69d45579dbd199a713e7a79acfa53033d24628dad23cff5" ++dependencies = [ ++ "proc-macro2", ++ "quote", ++ "syn", ++] ++ + [[package]] + name = "lino-objects-codec" + version = "0.2.1" +@@ -39,3 +51,38 @@ checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" + dependencies = [ + "memchr", + ] ++ ++[[package]] ++name = "proc-macro2" ++version = "1.0.107" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" ++dependencies = [ ++ "unicode-ident", ++] ++ ++[[package]] ++name = "quote" ++version = "1.0.47" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" ++dependencies = [ ++ "proc-macro2", ++] ++ ++[[package]] ++name = "syn" ++version = "3.0.3" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" ++dependencies = [ ++ "proc-macro2", ++ "quote", ++ "unicode-ident", ++] ++ ++[[package]] ++name = "unicode-ident" ++version = "1.0.24" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +diff --git a/rust/Cargo.toml b/rust/Cargo.toml +index ac773b1..22e0b57 100644 +--- a/rust/Cargo.toml ++++ b/rust/Cargo.toml +@@ -12,7 +12,7 @@ keywords = ["links-notation", "serialization", "codec", "object-graph", "circula + categories = ["encoding", "parser-implementations"] + + [dependencies] +-links-notation = "0.13.0" ++links-notation = "0.14.0" + base64 = "0.22" + + [dev-dependencies] +diff --git a/rust/README.md b/rust/README.md +index 251b405..8cf757a 100644 +--- a/rust/README.md ++++ b/rust/README.md +@@ -30,7 +30,8 @@ lino-objects-codec = "0.1" + - **Special Float Values**: Full support for NaN, Infinity, -Infinity (which are not valid JSON) + - **Circular References**: Detect and preserve circular references via object IDs + - **Object Identity**: Maintain object identity for shared references +-- **UTF-8 Support**: Full Unicode string support using base64 encoding ++- **Readable by Default**: `encode()` writes indented, plain-text Links Notation; keys and values stay legible and diffable ++- **UTF-8 Support**: Full Unicode string support written as text; only values that cannot be written as text (control characters) are base64-encoded, and each is marked individually + - **Simple API**: Easy-to-use `encode()` and `decode()` functions + + ## Quick Start +@@ -47,13 +48,35 @@ let data = LinoValue::object([ + + // Encode to Links Notation + let encoded = encode(&data); +-println!("Encoded: {}", encoded); ++assert_eq!(encoded, "(\n name \"Alice\"\n age 30\n active true\n)"); + + // Decode back + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded, data); + ``` + ++The encoded document reads as: ++ ++```lino ++( ++ name "Alice" ++ age 30 ++ active true ++) ++``` ++ ++## Output Formats ++ ++| Function | Output | ++| --- | --- | ++| `encode(value)` | Readable, indented Links Notation (the default) | ++| `encode_with_indent(value, "\t")` | Same, with a custom indentation string | ++| `encode_compact(value)` | The previous single-line base64 form | ++| `encode_obfuscated(value)` | Alias of `encode_compact` | ++ ++`decode()` accepts every one of them, so files written by older versions keep ++working and are rewritten in the readable form the next time they are saved. ++ + ## API Reference + + ### Types +@@ -97,21 +120,42 @@ pub enum CodecError { + + #### `encode(value: &LinoValue) -> String` + +-Encode a value to Links Notation format. ++Encode a value to the readable, indented Links Notation format. + + ```rust + let value = LinoValue::Int(42); + let encoded = encode(&value); +-assert_eq!(encoded, "(int 42)"); ++assert_eq!(encoded, "42"); + ``` + ++#### `encode_with_indent(value: &LinoValue, indent: &str) -> String` ++ ++Same as `encode()`, but with a custom indentation string (the default is two spaces). ++ ++#### `encode_compact(value: &LinoValue) -> String` ++ ++Encode a value to the single-line, base64 form used before version 0.3. ++ ++```rust ++let value = LinoValue::String("hello".to_string()); ++assert_eq!(encode_compact(&value), "(str aGVsbG8=)"); ++``` ++ ++#### `encode_obfuscated(value: &LinoValue) -> String` ++ ++Alias of `encode_compact()`, named after what the base64 form actually does to the text. ++ + #### `decode(notation: &str) -> Result` + +-Decode Links Notation format to a value. ++Decode Links Notation format to a value. Both the readable and the compact form ++are accepted. + + ```rust +-let decoded = decode("(int 42)").unwrap(); ++let decoded = decode("42").unwrap(); + assert_eq!(decoded, LinoValue::Int(42)); ++ ++let legacy = decode("(int 42)").unwrap(); ++assert_eq!(legacy, LinoValue::Int(42)); + ``` + + ### `ObjectCodec` +@@ -135,30 +179,34 @@ use lino_objects_codec::{encode, decode, LinoValue}; + + // Null + let null = LinoValue::Null; +-assert_eq!(encode(&null), "(null)"); ++assert_eq!(encode(&null), "null"); + + // Boolean + let bool_val = LinoValue::Bool(true); +-assert_eq!(encode(&bool_val), "(bool true)"); ++assert_eq!(encode(&bool_val), "true"); + + // Integer + let int_val = LinoValue::Int(42); +-assert_eq!(encode(&int_val), "(int 42)"); ++assert_eq!(encode(&int_val), "42"); + + // Float + let float_val = LinoValue::Float(3.14); +-assert!(encode(&float_val).starts_with("(float")); ++assert_eq!(encode(&float_val), "3.14"); + + // Special floats + let inf = LinoValue::Float(f64::INFINITY); +-assert_eq!(encode(&inf), "(float Infinity)"); ++assert_eq!(encode(&inf), "Infinity"); + + let nan = LinoValue::Float(f64::NAN); +-assert_eq!(encode(&nan), "(float NaN)"); ++assert_eq!(encode(&nan), "NaN"); + +-// String (base64 encoded) ++// Strings are quoted, not encoded + let str_val = LinoValue::String("hello".to_string()); +-assert_eq!(encode(&str_val), "(str aGVsbG8=)"); ++assert_eq!(encode(&str_val), "\"hello\""); ++ ++// Numbers written as strings stay strings ++let numeric = LinoValue::String("42".to_string()); ++assert_eq!(decode(&encode(&numeric)).unwrap(), numeric); + ``` + + ### Collections +@@ -235,7 +283,43 @@ let none_val: LinoValue = None::.into(); + + ## How It Works + +-The codec encodes values using the [Links Notation](https://github.com/link-foundation/links-notation) format: ++The codec encodes values using the [Links Notation](https://github.com/link-foundation/links-notation) format. ++ ++### Readable format (the default) ++ ++One `( )` construct carries both objects and arrays, at every level including ++the root. Lines of the form `key value` make an object, bare-value lines make an ++array: ++ ++```lino ++( ++ type "RouterState" ++ server ( ++ host "127.0.0.1" ++ port 18878 ++ ) ++ models ( ++ "claude-haiku" ++ "claude-opus" ++ ) ++) ++``` ++ ++- Strings are double-quoted and written as text: `name "Alice"` ++- Numbers, `true`, `false` and `null` are bare, so types survive a round trip ++- `NaN`, `Infinity` and `-Infinity` are written as such ++- An empty array is `()`; an empty object is `(` + newline + `)` ++- A value that cannot be written as text (one containing control characters) is ++ base64-encoded on its own and marked as `(base64 "bGluZTEKbGluZTI=")`; ++ everything around it stays readable ++ ++Reading the format back requires `links-notation` 0.14 semantics, where a ++parenthesis opens a nested indentation context. ++ ++### Compact format (`encode_compact`) ++ ++The previous single-line form, kept for compatibility and for cases where size ++matters more than legibility: + + - Basic types: `(int 42)`, `(str aGVsbG8=)`, `(bool true)` + - Strings are base64-encoded to handle special characters and newlines +@@ -243,10 +327,14 @@ The codec encodes values using the [Links Notation](https://github.com/link-foun + - Objects: `(object ((str a2V5) (int 42)) ...)` + - Special floats: `(float NaN)`, `(float Infinity)`, `(float -Infinity)` + +-For structures with shared references or circular references, the codec uses object IDs: ++For structures with shared references or circular references, the compact form ++uses object IDs: + - Format: `(obj_0: array ...)` or `(obj_0: object ...)` + - References: `obj_0` + ++`decode()` detects which of the two forms it is given, so previously written ++files keep decoding. ++ + ## Development + + ```bash +diff --git a/rust/changelog.d/20260820_120000_readable_default_format.md b/rust/changelog.d/20260820_120000_readable_default_format.md +new file mode 100644 +index 0000000..71872b3 +--- /dev/null ++++ b/rust/changelog.d/20260820_120000_readable_default_format.md +@@ -0,0 +1,14 @@ ++--- ++bump: minor ++--- ++ ++### Added ++- `encode_with_indent()` for choosing the indentation string of the readable format. ++- `encode_compact()` (alias `encode_obfuscated()`) keeping the previous single-line base64 output under an explicit name. ++- `readable` module with the indented encoder/decoder, plus `DEFAULT_INDENT` and `BASE64_MARKER` constants. ++ ++### Changed ++- `encode()` now produces indented, plain-text Links Notation by default: one `( )` construct for objects and arrays at every level, keys and values written verbatim, strings double-quoted, and numbers/`true`/`false`/`null` bare so types survive a round trip. ++- Values are base64-encoded only when they cannot be written as text (control characters), and each such value is marked individually as `(base64 "...")`. ++- `decode()` accepts both the readable and the previous compact form, so existing files keep working and migrate to the readable form on the next write. ++- Raised the `links-notation` dependency to 0.14, where parentheses open a nested indentation context. +diff --git a/rust/examples/basic_usage.rs b/rust/examples/basic_usage.rs +index a1c926a..7f75c2c 100644 +--- a/rust/examples/basic_usage.rs ++++ b/rust/examples/basic_usage.rs +@@ -1,6 +1,6 @@ + //! Basic usage example for the lino-objects-codec library. + +-use lino_objects_codec::{decode, encode, LinoValue}; ++use lino_objects_codec::{decode, encode, encode_compact, LinoValue}; + + fn main() { + println!("=== Links Notation Objects Codec - Rust Example ===\n"); +@@ -89,7 +89,7 @@ fn main() { + let array_val = LinoValue::array([LinoValue::Int(1), LinoValue::Int(2), LinoValue::Int(3)]); + let encoded = encode(&array_val); + let decoded = decode(&encoded).unwrap(); +- println!(" Array [1, 2, 3]: {}", encoded); ++ println!(" Array [1, 2, 3]:\n{}", encoded); + println!(" Decoded: {:?}", decoded); + + // Object +@@ -100,7 +100,7 @@ fn main() { + ]); + let encoded = encode(&obj_val); + let decoded = decode(&encoded).unwrap(); +- println!(" Object {{name, age, active}}: {}", encoded); ++ println!(" Object {{name, age, active}}:\n{}", encoded); + println!(" Decoded: {:?}", decoded); + + println!(); +@@ -130,7 +130,7 @@ fn main() { + let encoded = encode(&complex); + let decoded = decode(&encoded).unwrap(); + +- println!(" Encoded: {}", encoded); ++ println!(" Encoded:\n{}", encoded); + println!(" Decoded name: {:?}", decoded.get("name")); + println!(" Decoded tags: {:?}", decoded.get("tags")); + println!( +@@ -154,7 +154,7 @@ fn main() { + let encoded = encode(&mixed); + let decoded = decode(&encoded).unwrap(); + +- println!(" Encoded: {}", encoded); ++ println!(" Encoded:\n{}", encoded); + println!(" Decoded: {:?}", decoded); + + println!(); +@@ -184,5 +184,17 @@ fn main() { + + println!(" Original == Decoded: {}", data == decoded); + ++ println!(); ++ ++ // Example 6: The compact (base64) form is still available under its own name ++ println!("6. Compact Form:"); ++ ++ let compact = encode_compact(&data); ++ println!(" Compact: {}", compact); ++ println!( ++ " Compact decodes back to the same value: {}", ++ decode(&compact).unwrap() == data ++ ); ++ + println!("\n=== Example completed successfully! ==="); + } +diff --git a/rust/src/lib.rs b/rust/src/lib.rs +index 00f8b2b..7934c39 100644 +--- a/rust/src/lib.rs ++++ b/rust/src/lib.rs +@@ -6,12 +6,15 @@ + //! + //! # Features + //! ++//! - **Readable by Default**: `encode()` writes plain, indented text that can be read and reviewed + //! - **Universal Serialization**: Encode objects to Links Notation format + //! - **Type Support**: Handle all common types: null, boolean, integer, float, string, array, object + //! - **Special Float Values**: Support for NaN, Infinity, -Infinity (which are not valid JSON) + //! - **Circular References**: Detect and preserve circular references (via object IDs) + //! - **Object Identity**: Maintain object identity for shared references +-//! - **UTF-8 Support**: Full Unicode string support using base64 encoding ++//! - **UTF-8 Support**: Full Unicode string support, written as text; only values that cannot be ++//! represented as text (strings holding control characters) are base64-encoded, and they are ++//! marked individually as `(base64 "…")` + //! - **Simple API**: Easy-to-use `encode()` and `decode()` functions + //! + //! # Example +@@ -26,16 +29,32 @@ + //! ("active", LinoValue::Bool(true)), + //! ]); + //! let encoded = encode(&data); ++//! assert_eq!(encoded, "(\n name \"Alice\"\n age 30\n active true\n)"); + //! let decoded = decode(&encoded).unwrap(); + //! assert_eq!(decoded, data); + //! ``` ++//! ++//! # Output formats ++//! ++//! | Function | Output | ++//! |---|---| ++//! | [`encode`] | readable, indented plain text (default) | ++//! | [`encode_with_indent`] | the same, with a custom indentation string | ++//! | [`encode_compact`] / [`encode_obfuscated`] | the previous single-line, base64 form | ++//! ++//! [`decode`] accepts both forms, so files written by earlier versions keep working ++//! and migrate to the readable form on the next write. + + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + use links_notation::{parse_lino_to_links, LiNo}; + use std::collections::{HashMap, HashSet}; + use std::fmt; + +-/// Type identifiers used in Links Notation format ++pub mod readable; ++ ++pub use readable::{BASE64_MARKER, DEFAULT_INDENT}; ++ ++/// Type identifiers used in the compact (base64) Links Notation format + mod type_ids { + pub const NULL: &str = "null"; + pub const BOOL: &str = "bool"; +@@ -385,7 +404,11 @@ impl ObjectCodec { + } + } + +- /// Encode a LinoValue to Links Notation format. ++ /// Encode a LinoValue to the readable, indented Links Notation format. ++ /// ++ /// This is the default representation: keys and values are written as plain ++ /// text, one per line, so the result can be read and reviewed directly. ++ /// See [`readable`] for the exact shape. + /// + /// # Arguments + /// +@@ -393,8 +416,35 @@ impl ObjectCodec { + /// + /// # Returns + /// +- /// A string in Links Notation format ++ /// A string in readable Links Notation format + pub fn encode(&mut self, value: &LinoValue) -> String { ++ readable::encode(value, DEFAULT_INDENT) ++ } ++ ++ /// Encode a LinoValue to the readable format using a custom indentation string. ++ /// ++ /// # Arguments ++ /// ++ /// * `value` - The value to encode ++ /// * `indent` - The indentation string used per nesting level (for example `" "`) ++ pub fn encode_with_indent(&mut self, value: &LinoValue, indent: &str) -> String { ++ readable::encode(value, indent) ++ } ++ ++ /// Encode a LinoValue to the compact, single-line Links Notation format. ++ /// ++ /// Every value is tagged with its type and every string is base64-encoded, so ++ /// the whole document fits on one line and carries no readable text. This was ++ /// the default before the readable format; callers now opt into it explicitly. ++ /// ++ /// # Arguments ++ /// ++ /// * `value` - The value to encode ++ /// ++ /// # Returns ++ /// ++ /// A string in compact Links Notation format ++ pub fn encode_compact(&mut self, value: &LinoValue) -> String { + self.reset_encode_state(); + + // First pass: identify which objects need IDs +@@ -431,6 +481,14 @@ impl ObjectCodec { + } + } + ++ /// Encode a LinoValue to the compact, base64 form. ++ /// ++ /// Alias of [`ObjectCodec::encode_compact`], named after what the form does to ++ /// its content: nothing in the output can be read without decoding it. ++ pub fn encode_obfuscated(&mut self, value: &LinoValue) -> String { ++ self.encode_compact(value) ++ } ++ + /// Format a single link to its string representation. + fn format_link(link: &LiNo) -> String { + match link { +@@ -635,7 +693,31 @@ impl ObjectCodec { + /// # Returns + /// + /// The reconstructed value, or an error ++ /// ++ /// Both the readable format and the compact (base64) format are accepted, so ++ /// files written by earlier versions keep working and migrate on next write. + pub fn decode(&mut self, notation: &str) -> Result { ++ if notation.trim().is_empty() { ++ return Ok(LinoValue::Null); ++ } ++ ++ if is_compact_notation(notation) { ++ return self.decode_compact(notation); ++ } ++ ++ readable::decode(notation) ++ } ++ ++ /// Decode the compact (base64) Links Notation format. ++ /// ++ /// # Arguments ++ /// ++ /// * `notation` - String in compact Links Notation format ++ /// ++ /// # Returns ++ /// ++ /// The reconstructed value, or an error ++ pub fn decode_compact(&mut self, notation: &str) -> Result { + self.reset_decode_state(); + + let links = parse_lino_to_links(notation) +@@ -836,12 +918,57 @@ impl ObjectCodec { + } + } + ++/// Detect the compact (base64) format. ++/// ++/// Compact output always starts a line with `(` immediately followed by a type ++/// marker — optionally preceded by an object id, as in `(obj_0: object …)`. ++/// Readable output never does: its first line is either a lone `(` or a scalar. ++fn is_compact_notation(notation: &str) -> bool { ++ let Some(first_line) = notation.lines().map(str::trim).find(|l| !l.is_empty()) else { ++ return false; ++ }; ++ ++ let Some(rest) = first_line.strip_prefix('(') else { ++ return false; ++ }; ++ ++ let mut tokens = rest ++ .split(|c: char| c.is_whitespace() || c == '(' || c == ')') ++ .filter(|t| !t.is_empty()); ++ ++ let Some(mut marker) = tokens.next() else { ++ return false; ++ }; ++ ++ // Skip the `obj_N:` definition id, if present. ++ if let Some(id) = marker.strip_suffix(':') { ++ if !id.starts_with("obj_") { ++ return false; ++ } ++ let Some(next) = tokens.next() else { ++ return false; ++ }; ++ marker = next; ++ } ++ ++ matches!( ++ marker, ++ type_ids::NULL ++ | type_ids::BOOL ++ | type_ids::INT ++ | type_ids::FLOAT ++ | type_ids::STR ++ | type_ids::ARRAY ++ | type_ids::OBJECT ++ ) ++} ++ + // Global codec instance for convenience functions + thread_local! { + static DEFAULT_CODEC: std::cell::RefCell = std::cell::RefCell::new(ObjectCodec::new()); + } + +-/// Encode a value to Links Notation format. ++/// Encode a value to the readable, indented Links Notation format. + /// + /// This is a convenience function that uses a thread-local codec instance. + /// +@@ -851,7 +978,7 @@ thread_local! { + /// + /// # Returns + /// +-/// A string in Links Notation format ++/// A string in readable Links Notation format + /// + /// # Example + /// +@@ -863,13 +990,68 @@ thread_local! { + /// ("age", LinoValue::Int(30)), + /// ]); + /// let encoded = encode(&data); +-/// // String "Alice" is base64-encoded as "QWxpY2U=" +-/// assert!(encoded.contains("QWxpY2U=")); ++/// // Names and values are written as they are, one per line ++/// assert_eq!(encoded, "(\n name \"Alice\"\n age 30\n)"); + /// ``` + pub fn encode(value: &LinoValue) -> String { + DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode(value)) + } + ++/// Encode a value to the readable format using a custom indentation string. ++/// ++/// # Arguments ++/// ++/// * `value` - The value to encode ++/// * `indent` - The indentation string used per nesting level ++/// ++/// # Example ++/// ++/// ```rust ++/// use lino_objects_codec::{encode_with_indent, LinoValue}; ++/// ++/// let data = LinoValue::object([("age", LinoValue::Int(30))]); ++/// assert_eq!(encode_with_indent(&data, " "), "(\n age 30\n)"); ++/// ``` ++pub fn encode_with_indent(value: &LinoValue, indent: &str) -> String { ++ DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_with_indent(value, indent)) ++} ++ ++/// Encode a value to the compact, single-line Links Notation format. ++/// ++/// Every string is base64-encoded and the whole document is written on one line. ++/// [`decode`] reads this form as well, so stored files remain readable by the ++/// library after switching to the default readable output. ++/// ++/// # Arguments ++/// ++/// * `value` - The value to encode ++/// ++/// # Returns ++/// ++/// A string in compact Links Notation format ++/// ++/// # Example ++/// ++/// ```rust ++/// use lino_objects_codec::{encode_compact, decode, LinoValue}; ++/// ++/// let data = LinoValue::object([("name", LinoValue::String("Alice".to_string()))]); ++/// let encoded = encode_compact(&data); ++/// // String "Alice" is base64-encoded as "QWxpY2U=" ++/// assert!(encoded.contains("QWxpY2U=")); ++/// assert_eq!(decode(&encoded).unwrap(), data); ++/// ``` ++pub fn encode_compact(value: &LinoValue) -> String { ++ DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_compact(value)) ++} ++ ++/// Encode a value to the compact, base64 form. ++/// ++/// Alias of [`encode_compact`], named after what the form does to its content. ++pub fn encode_obfuscated(value: &LinoValue) -> String { ++ DEFAULT_CODEC.with(|codec| codec.borrow_mut().encode_obfuscated(value)) ++} ++ + /// Decode Links Notation format to a value. + /// + /// This is a convenience function that uses a thread-local codec instance. +diff --git a/rust/src/readable.rs b/rust/src/readable.rs +new file mode 100644 +index 0000000..79af927 +--- /dev/null ++++ b/rust/src/readable.rs +@@ -0,0 +1,591 @@ ++//! Readable, indented Links Notation representation. ++//! ++//! This module implements the default output of [`crate::encode`]: a plain-text, ++//! indented projection where keys and values are written as they are, so the file ++//! can be read, grepped and reviewed without decoding anything. ++//! ++//! # Shape ++//! ++//! One construct — `( )` — is used for both objects and arrays, at every level ++//! including the root. What distinguishes them is the content of the lines: ++//! `key value` pairs make an object, bare values make an array. ++//! ++//! ```text ++//! ( ++//! type "RouterState" ++//! server ( ++//! host "127.0.0.1" ++//! port 18878 ++//! ) ++//! models ( ++//! "claude-haiku" ++//! "claude-opus" ++//! ) ++//! ) ++//! ``` ++//! ++//! # Value mapping ++//! ++//! | `LinoValue` | Readable form | ++//! |----------------------------|------------------------------------------------| ++//! | `Object` | `( )` with one `key value` pair per line | ++//! | `Array` | `( )` with one value per line | ++//! | `String` | quoted, never encoded | ++//! | `Int` / `Float` / `Bool` / `Null` | bare, so the type survives the round trip | ++//! ++//! Empty containers keep their type: an empty array is `()` on one line, while an ++//! empty object is written as `(` and `)` on two lines. ++//! ++//! Only values that cannot be written as plain text are encoded: strings holding ++//! control characters (including newlines and tabs, which line-based tooling and ++//! CRLF normalisation would corrupt) are marked individually as ++//! `(base64 "…")` instead of encoding the whole document. ++ ++use crate::{CodecError, LinoValue}; ++use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; ++ ++/// Default indentation used by [`encode`]. ++pub const DEFAULT_INDENT: &str = " "; ++ ++/// Marker used for values that cannot be represented as plain text. ++pub const BASE64_MARKER: &str = "base64"; ++ ++/// Encode a value into the readable, indented Links Notation form. ++pub fn encode(value: &LinoValue, indent: &str) -> String { ++ let mut out = String::new(); ++ write_value(value, indent, 0, &mut out); ++ out ++} ++ ++/// Decode the readable, indented Links Notation form back into a value. ++pub fn decode(text: &str) -> Result { ++ let tokens = tokenize(text)?; ++ let mut cursor = Cursor { tokens, pos: 0 }; ++ let rows = cursor.parse_rows(true)?; ++ ++ if cursor.pos < cursor.tokens.len() { ++ return Err(CodecError::ParseError( ++ "unexpected ')' in readable notation".to_string(), ++ )); ++ } ++ ++ // A document holding a single value (for example `42`) is that value. ++ if rows.len() == 1 && rows[0].len() == 1 { ++ return node_to_value(&rows[0][0]); ++ } ++ ++ rows_to_value(&rows, true) ++} ++ ++// === Encoding === ++ ++fn write_value(value: &LinoValue, indent: &str, level: usize, out: &mut String) { ++ match value { ++ LinoValue::Object(pairs) => { ++ if pairs.is_empty() { ++ // An empty object spans two lines; `()` on one line is an empty array. ++ out.push_str("(\n"); ++ push_indent(indent, level, out); ++ out.push(')'); ++ return; ++ } ++ ++ out.push('('); ++ for (key, child) in pairs { ++ out.push('\n'); ++ push_indent(indent, level + 1, out); ++ out.push_str(&format_key(key)); ++ out.push(' '); ++ write_value(child, indent, level + 1, out); ++ } ++ out.push('\n'); ++ push_indent(indent, level, out); ++ out.push(')'); ++ } ++ ++ LinoValue::Array(items) => { ++ if items.is_empty() { ++ out.push_str("()"); ++ return; ++ } ++ ++ out.push('('); ++ for item in items { ++ out.push('\n'); ++ push_indent(indent, level + 1, out); ++ write_value(item, indent, level + 1, out); ++ } ++ out.push('\n'); ++ push_indent(indent, level, out); ++ out.push(')'); ++ } ++ ++ scalar => out.push_str(&format_scalar(scalar)), ++ } ++} ++ ++fn push_indent(indent: &str, level: usize, out: &mut String) { ++ for _ in 0..level { ++ out.push_str(indent); ++ } ++} ++ ++/// Format a scalar value. Strings are quoted, everything else stays bare so that ++/// its type is recoverable when reading the document back. ++fn format_scalar(value: &LinoValue) -> String { ++ match value { ++ LinoValue::Null => "null".to_string(), ++ LinoValue::Bool(b) => b.to_string(), ++ LinoValue::Int(i) => i.to_string(), ++ LinoValue::Float(f) => format_float(*f), ++ LinoValue::String(s) => format_string(s), ++ // Containers are handled by write_value. ++ LinoValue::Array(_) | LinoValue::Object(_) => String::new(), ++ } ++} ++ ++fn format_float(f: f64) -> String { ++ if f.is_nan() { ++ "NaN".to_string() ++ } else if f.is_infinite() { ++ if f.is_sign_positive() { ++ "Infinity".to_string() ++ } else { ++ "-Infinity".to_string() ++ } ++ } else { ++ // `{:?}` keeps the decimal point for whole floats (`1.0`), which is what ++ // tells a float apart from an integer when reading the document back. ++ format!("{:?}", f) ++ } ++} ++ ++/// Format a string value: quoted plain text, or an individually marked ++/// base64 payload when the text cannot be written literally. ++fn format_string(value: &str) -> String { ++ if needs_encoding(value) { ++ return format!( ++ "({} {})", ++ BASE64_MARKER, ++ quote(&BASE64.encode(value.as_bytes())) ++ ); ++ } ++ quote(value) ++} ++ ++/// A value can be written as text unless it contains control characters: ++/// newlines break the line structure and CRLF normalisation would rewrite them. ++fn needs_encoding(value: &str) -> bool { ++ value.chars().any(char::is_control) ++} ++ ++fn quote(value: &str) -> String { ++ let has_double = value.contains('"'); ++ let has_single = value.contains('\''); ++ ++ if !has_double { ++ return format!("\"{}\"", value); ++ } ++ if !has_single { ++ return format!("'{}'", value); ++ } ++ // Both quote styles are present: double the double quotes, as the parser expects. ++ format!("\"{}\"", value.replace('"', "\"\"")) ++} ++ ++/// Format an object key. Keys are bare when they read as plain identifiers. ++fn format_key(key: &str) -> String { ++ let plain = !key.is_empty() ++ && key != BASE64_MARKER ++ && !needs_encoding(key) ++ && !key ++ .chars() ++ .any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '\'' | '"' | ':' | '`')); ++ ++ if plain { ++ key.to_string() ++ } else { ++ format_string(key) ++ } ++} ++ ++// === Decoding === ++ ++#[derive(Debug, Clone, PartialEq, Eq)] ++enum Token { ++ Open, ++ Close, ++ Newline, ++ Ref { value: String, quoted: bool }, ++} ++ ++/// A parsed element of the readable form: either a reference (remembering whether ++/// it was quoted, which is what distinguishes a string from a number) or a link. ++#[derive(Debug, Clone)] ++enum Node { ++ Ref { ++ value: String, ++ quoted: bool, ++ }, ++ Link { ++ rows: Vec>, ++ multiline: bool, ++ }, ++} ++ ++fn tokenize(text: &str) -> Result, CodecError> { ++ let chars: Vec = text.chars().collect(); ++ let mut tokens = Vec::new(); ++ let mut i = 0; ++ ++ while i < chars.len() { ++ let c = chars[i]; ++ ++ if c == '\n' { ++ tokens.push(Token::Newline); ++ i += 1; ++ } else if c.is_whitespace() { ++ i += 1; ++ } else if c == '(' { ++ tokens.push(Token::Open); ++ i += 1; ++ } else if c == ')' { ++ tokens.push(Token::Close); ++ i += 1; ++ } else if matches!(c, '"' | '\'' | '`') { ++ let (value, next) = read_quoted(&chars, i, c)?; ++ tokens.push(Token::Ref { ++ value, ++ quoted: true, ++ }); ++ i = next; ++ } else { ++ let start = i; ++ while i < chars.len() ++ && !chars[i].is_whitespace() ++ && !matches!(chars[i], '(' | ')' | '"' | '\'' | '`') ++ { ++ i += 1; ++ } ++ tokens.push(Token::Ref { ++ value: chars[start..i].iter().collect(), ++ quoted: false, ++ }); ++ } ++ } ++ ++ Ok(tokens) ++} ++ ++/// Read a quoted reference, where a doubled quote character means a literal one. ++fn read_quoted( ++ chars: &[char], ++ start: usize, ++ quote_char: char, ++) -> Result<(String, usize), CodecError> { ++ let mut value = String::new(); ++ let mut i = start + 1; ++ ++ while i < chars.len() { ++ if chars[i] == quote_char { ++ if chars.get(i + 1) == Some("e_char) { ++ value.push(quote_char); ++ i += 2; ++ continue; ++ } ++ return Ok((value, i + 1)); ++ } ++ value.push(chars[i]); ++ i += 1; ++ } ++ ++ Err(CodecError::ParseError(format!( ++ "unterminated quoted value starting at character {}", ++ start ++ ))) ++} ++ ++struct Cursor { ++ tokens: Vec, ++ pos: usize, ++} ++ ++impl Cursor { ++ /// Parse rows until the matching `)` (or the end of input at the top level). ++ /// A row is one line: the values written between two newlines. ++ fn parse_rows(&mut self, top_level: bool) -> Result>, CodecError> { ++ let mut rows: Vec> = Vec::new(); ++ let mut row: Vec = Vec::new(); ++ ++ while self.pos < self.tokens.len() { ++ match &self.tokens[self.pos] { ++ Token::Close => { ++ if top_level { ++ break; ++ } ++ self.pos += 1; ++ if !row.is_empty() { ++ rows.push(row); ++ } ++ return Ok(rows); ++ } ++ Token::Newline => { ++ self.pos += 1; ++ if !row.is_empty() { ++ rows.push(std::mem::take(&mut row)); ++ } ++ } ++ _ => row.push(self.parse_node()?), ++ } ++ } ++ ++ if !top_level { ++ return Err(CodecError::ParseError( ++ "unterminated '(' in readable notation".to_string(), ++ )); ++ } ++ ++ if !row.is_empty() { ++ rows.push(row); ++ } ++ Ok(rows) ++ } ++ ++ fn parse_node(&mut self) -> Result { ++ match self.tokens[self.pos].clone() { ++ Token::Ref { value, quoted } => { ++ self.pos += 1; ++ Ok(Node::Ref { value, quoted }) ++ } ++ Token::Open => { ++ self.pos += 1; ++ let multiline = self.link_is_multiline(); ++ let rows = self.parse_rows(false)?; ++ Ok(Node::Link { rows, multiline }) ++ } ++ Token::Close | Token::Newline => Err(CodecError::ParseError( ++ "unexpected token in readable notation".to_string(), ++ )), ++ } ++ } ++ ++ /// Whether the link that just opened spans more than one line, which is what ++ /// tells an empty object (`(\n)`) from an empty array (`()`). ++ fn link_is_multiline(&self) -> bool { ++ self.tokens[self.pos..] ++ .iter() ++ .take_while(|t| **t != Token::Close) ++ .any(|t| *t == Token::Newline) ++ } ++} ++ ++fn node_to_value(node: &Node) -> Result { ++ match node { ++ Node::Ref { value, quoted } => Ok(ref_to_value(value, *quoted)), ++ Node::Link { rows, multiline } => rows_to_value(rows, *multiline), ++ } ++} ++ ++fn rows_to_value(rows: &[Vec], multiline: bool) -> Result { ++ if rows.is_empty() { ++ return Ok(if multiline { ++ LinoValue::Object(vec![]) ++ } else { ++ LinoValue::Array(vec![]) ++ }); ++ } ++ ++ if let Some(marked) = decode_marked_value(rows) { ++ return marked; ++ } ++ ++ // `key value` on every line makes an object; anything else is a list of values. ++ let is_object = rows ++ .iter() ++ .all(|row| row.len() == 2 && matches!(row[0], Node::Ref { .. })); ++ ++ if is_object { ++ let mut pairs = Vec::with_capacity(rows.len()); ++ for row in rows { ++ let Node::Ref { value: key, .. } = &row[0] else { ++ unreachable!("checked by is_object") ++ }; ++ pairs.push((key.clone(), node_to_value(&row[1])?)); ++ } ++ return Ok(LinoValue::Object(pairs)); ++ } ++ ++ let mut items = Vec::new(); ++ for row in rows { ++ for node in row { ++ items.push(node_to_value(node)?); ++ } ++ } ++ Ok(LinoValue::Array(items)) ++} ++ ++/// Recognise `(base64 "…")`, the individual marker for values that could not be ++/// written as text. A quoted `base64` key is an ordinary object key, not a marker. ++fn decode_marked_value(rows: &[Vec]) -> Option> { ++ if rows.len() != 1 || rows[0].len() != 2 { ++ return None; ++ } ++ ++ let Node::Ref { ++ value: marker, ++ quoted: false, ++ } = &rows[0][0] ++ else { ++ return None; ++ }; ++ if marker != BASE64_MARKER { ++ return None; ++ } ++ ++ let Node::Ref { ++ value: payload, ++ quoted: true, ++ } = &rows[0][1] ++ else { ++ return None; ++ }; ++ ++ Some( ++ BASE64 ++ .decode(payload) ++ .map_err(|e| CodecError::DecodeError(format!("invalid base64 value: {}", e))) ++ .and_then(|bytes| { ++ String::from_utf8(bytes) ++ .map(LinoValue::String) ++ .map_err(|e| CodecError::DecodeError(format!("invalid UTF-8 value: {}", e))) ++ }), ++ ) ++} ++ ++/// Convert a reference to a value. Quoted references are always strings; bare ++/// references keep the type they were written with. ++fn ref_to_value(value: &str, quoted: bool) -> LinoValue { ++ if quoted { ++ return LinoValue::String(value.to_string()); ++ } ++ ++ match value { ++ "null" => return LinoValue::Null, ++ "true" => return LinoValue::Bool(true), ++ "false" => return LinoValue::Bool(false), ++ "NaN" => return LinoValue::Float(f64::NAN), ++ "Infinity" => return LinoValue::Float(f64::INFINITY), ++ "-Infinity" => return LinoValue::Float(f64::NEG_INFINITY), ++ _ => {} ++ } ++ ++ if let Ok(i) = value.parse::() { ++ return LinoValue::Int(i); ++ } ++ if value.contains(['.', 'e', 'E']) { ++ if let Ok(f) = value.parse::() { ++ return LinoValue::Float(f); ++ } ++ } ++ ++ LinoValue::String(value.to_string()) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ fn roundtrip(value: &LinoValue) -> LinoValue { ++ let text = encode(value, DEFAULT_INDENT); ++ decode(&text).unwrap_or_else(|e| panic!("failed to decode {:?}: {}", text, e)) ++ } ++ ++ #[test] ++ fn empty_containers_keep_their_type() { ++ assert_eq!(encode(&LinoValue::Array(vec![]), DEFAULT_INDENT), "()"); ++ assert_eq!(encode(&LinoValue::Object(vec![]), DEFAULT_INDENT), "(\n)"); ++ assert_eq!( ++ roundtrip(&LinoValue::Array(vec![])), ++ LinoValue::Array(vec![]) ++ ); ++ assert_eq!( ++ roundtrip(&LinoValue::Object(vec![])), ++ LinoValue::Object(vec![]) ++ ); ++ } ++ ++ #[test] ++ fn single_pair_object_is_not_an_array() { ++ let value = LinoValue::object([("a", LinoValue::Int(1))]); ++ assert_eq!(encode(&value, DEFAULT_INDENT), "(\n a 1\n)"); ++ assert_eq!(roundtrip(&value), value); ++ } ++ ++ #[test] ++ fn numeric_looking_strings_stay_strings() { ++ let value = LinoValue::object([ ++ ("count", LinoValue::Int(42)), ++ ("zip", LinoValue::String("10001".to_string())), ++ ("flag", LinoValue::String("true".to_string())), ++ ]); ++ assert_eq!(roundtrip(&value), value); ++ } ++ ++ #[test] ++ fn whole_floats_stay_floats() { ++ let value = LinoValue::Float(1.0); ++ assert_eq!(encode(&value, DEFAULT_INDENT), "1.0"); ++ assert!(matches!(roundtrip(&value), LinoValue::Float(f) if (f - 1.0).abs() < f64::EPSILON)); ++ } ++ ++ #[test] ++ fn base64_key_is_quoted_so_it_is_not_a_marker() { ++ let value = LinoValue::object([("base64", LinoValue::String("plain".to_string()))]); ++ assert_eq!( ++ encode(&value, DEFAULT_INDENT), ++ "(\n \"base64\" \"plain\"\n)" ++ ); ++ assert_eq!(roundtrip(&value), value); ++ } ++ ++ #[test] ++ fn control_characters_are_marked_individually() { ++ let value = LinoValue::object([ ++ ("plain", LinoValue::String("visible".to_string())), ++ ("raw", LinoValue::String("line1\nline2".to_string())), ++ ]); ++ let text = encode(&value, DEFAULT_INDENT); ++ assert!(text.contains("plain \"visible\""), "{}", text); ++ assert!( ++ text.contains("raw (base64 \"bGluZTEKbGluZTI=\")"), ++ "{}", ++ text ++ ); ++ assert_eq!(roundtrip(&value), value); ++ } ++ ++ #[test] ++ fn custom_indent_is_used() { ++ let value = LinoValue::object([("a", LinoValue::Int(1))]); ++ assert_eq!(encode(&value, " "), "(\n a 1\n)"); ++ } ++ ++ #[test] ++ fn handwritten_document_without_root_parentheses_decodes() { ++ let decoded = decode("a 1\nb \"two\"").unwrap(); ++ assert_eq!( ++ decoded, ++ LinoValue::object([ ++ ("a", LinoValue::Int(1)), ++ ("b", LinoValue::String("two".into())) ++ ]) ++ ); ++ } ++ ++ #[test] ++ fn unterminated_input_is_an_error() { ++ assert!(decode("(\n a 1\n").is_err()); ++ assert!(decode("(\n a \"unterminated\n").is_err()); ++ assert!(decode("a 1)").is_err()); ++ } ++} +diff --git a/rust/tests/documented_examples.rs b/rust/tests/documented_examples.rs +new file mode 100644 +index 0000000..30d1e04 +--- /dev/null ++++ b/rust/tests/documented_examples.rs +@@ -0,0 +1,52 @@ ++//! Checks that the snippets shown in `README.md` and the crate docs stay true. ++ ++use lino_objects_codec::{decode, encode, encode_compact, LinoValue}; ++ ++#[test] ++fn scalars_are_written_as_documented() { ++ assert_eq!(encode(&LinoValue::Null), "null"); ++ assert_eq!(encode(&LinoValue::Bool(true)), "true"); ++ assert_eq!(encode(&LinoValue::Int(42)), "42"); ++ assert_eq!(encode(&LinoValue::Float(3.14)), "3.14"); ++ assert_eq!(encode(&LinoValue::Float(f64::INFINITY)), "Infinity"); ++ assert_eq!(encode(&LinoValue::Float(f64::NAN)), "NaN"); ++ assert_eq!(encode(&LinoValue::String("hello".into())), "\"hello\""); ++} ++ ++#[test] ++fn quick_start_object_matches_the_readme() { ++ let data = LinoValue::object([ ++ ("name", LinoValue::String("Alice".to_string())), ++ ("age", LinoValue::Int(30)), ++ ("active", LinoValue::Bool(true)), ++ ]); ++ ++ assert_eq!( ++ encode(&data), ++ "(\n name \"Alice\"\n age 30\n active true\n)" ++ ); ++} ++ ++#[test] ++fn empty_containers_are_written_as_documented() { ++ assert_eq!(encode(&LinoValue::Array(vec![])), "()"); ++ assert_eq!(encode(&LinoValue::Object(vec![])), "(\n)"); ++} ++ ++#[test] ++fn unrepresentable_values_use_the_documented_marker() { ++ assert_eq!( ++ encode(&LinoValue::String("line1\nline2".into())), ++ "(base64 \"bGluZTEKbGluZTI=\")" ++ ); ++} ++ ++#[test] ++fn both_forms_decode_as_documented() { ++ assert_eq!( ++ encode_compact(&LinoValue::String("hello".into())), ++ "(str aGVsbG8=)" ++ ); ++ assert_eq!(decode("42").unwrap(), LinoValue::Int(42)); ++ assert_eq!(decode("(int 42)").unwrap(), LinoValue::Int(42)); ++} +diff --git a/rust/tests/readable_format.rs b/rust/tests/readable_format.rs +new file mode 100644 +index 0000000..4a2feed +--- /dev/null ++++ b/rust/tests/readable_format.rs +@@ -0,0 +1,368 @@ ++//! Tests for the readable, indented format produced by `encode()` (issue #37). ++ ++use links_notation::{parse_lino_to_links, LiNo}; ++use lino_objects_codec::{decode, encode, encode_compact, encode_obfuscated, LinoValue}; ++ ++/// The document from the issue, as a `LinoValue`. ++fn router_state() -> LinoValue { ++ LinoValue::object([ ++ ("type", LinoValue::String("RouterState".to_string())), ++ ( ++ "server", ++ LinoValue::object([ ++ ("host", LinoValue::String("127.0.0.1".to_string())), ++ ("port", LinoValue::Int(18878)), ++ ]), ++ ), ++ ( ++ "models", ++ LinoValue::array([ ++ LinoValue::String("claude-haiku".to_string()), ++ LinoValue::String("claude-opus".to_string()), ++ ]), ++ ), ++ ( ++ "value", ++ LinoValue::array([ ++ LinoValue::object([ ++ ("id", LinoValue::String("7cf7abf6".to_string())), ++ ("label", LinoValue::String("bootstrap-admin".to_string())), ++ ("ttl_hours", LinoValue::Int(24)), ++ ("revoked", LinoValue::Bool(false)), ++ ]), ++ LinoValue::object([ ++ ("id", LinoValue::String("94b36f7e".to_string())), ++ ("label", LinoValue::String("wrapper-run".to_string())), ++ ("ttl_hours", LinoValue::Int(720)), ++ ("revoked", LinoValue::Bool(false)), ++ ]), ++ ]), ++ ), ++ ]) ++} ++ ++#[test] ++fn encode_writes_keys_and_values_verbatim() { ++ let encoded = encode(&router_state()); ++ ++ for expected in [ ++ "type \"RouterState\"", ++ "host \"127.0.0.1\"", ++ "port 18878", ++ "\"claude-haiku\"", ++ "label \"bootstrap-admin\"", ++ "revoked false", ++ ] { ++ assert!( ++ encoded.contains(expected), ++ "missing {expected} in:\n{encoded}" ++ ); ++ } ++ ++ // Nothing is base64-encoded any more. ++ assert!(!encoded.contains("Um91dGVyU3RhdGU="), "{encoded}"); ++} ++ ++#[test] ++fn encode_spans_multiple_indented_lines() { ++ let encoded = encode(&router_state()); ++ let lines: Vec<&str> = encoded.lines().collect(); ++ ++ assert!( ++ lines.len() > 10, ++ "expected an indented document:\n{encoded}" ++ ); ++ assert_eq!(lines[0], "("); ++ assert_eq!(lines[1], " type \"RouterState\""); ++ assert_eq!(*lines.last().unwrap(), ")"); ++ // Nested values are indented deeper than their key. ++ assert!(encoded.contains("\n host \"127.0.0.1\""), "{encoded}"); ++} ++ ++#[test] ++fn encode_matches_the_documented_shape() { ++ let value = LinoValue::object([ ++ ("type", LinoValue::String("RouterState".to_string())), ++ ( ++ "server", ++ LinoValue::object([ ++ ("host", LinoValue::String("127.0.0.1".to_string())), ++ ("port", LinoValue::Int(18878)), ++ ]), ++ ), ++ ( ++ "models", ++ LinoValue::array([ ++ LinoValue::String("claude-haiku".to_string()), ++ LinoValue::String("claude-opus".to_string()), ++ ]), ++ ), ++ ]); ++ ++ let expected = "(\n \ ++ type \"RouterState\"\n \ ++ server (\n \ ++ host \"127.0.0.1\"\n \ ++ port 18878\n \ ++ )\n \ ++ models (\n \ ++ \"claude-haiku\"\n \ ++ \"claude-opus\"\n \ ++ )\n\ ++ )"; ++ ++ assert_eq!(encode(&value), expected); ++} ++ ++#[test] ++fn readable_output_is_valid_links_notation() { ++ let encoded = encode(&router_state()); ++ let links = parse_lino_to_links(&encoded) ++ .unwrap_or_else(|e| panic!("links-notation rejected the output: {e:?}\n{encoded}")); ++ assert_eq!( ++ links.len(), ++ 1, ++ "expected a single document link:\n{encoded}" ++ ); ++ ++ // Parentheses open a nested indentation context (links-notation >= 0.14): ++ // `server` holds one link of two pairs, not four loose references. ++ let LiNo::Link { values, .. } = &links[0] else { ++ panic!("expected a link:\n{encoded}"); ++ }; ++ let server = values ++ .iter() ++ .find_map(|v| match v { ++ LiNo::Link { values: pair, .. } ++ if matches!(pair.first(), Some(LiNo::Ref(k)) if k == "server") => ++ { ++ pair.get(1) ++ } ++ _ => None, ++ }) ++ .expect("server pair"); ++ ++ let LiNo::Link { values: fields, .. } = server else { ++ panic!("server should be a link:\n{encoded}"); ++ }; ++ assert_eq!(fields.len(), 2, "server fields were flattened: {fields:?}"); ++ assert!(fields.iter().all(LiNo::is_link), "{fields:?}"); ++} ++ ++#[test] ++fn nested_structures_roundtrip() { ++ let value = router_state(); ++ assert_eq!(decode(&encode(&value)).unwrap(), value); ++} ++ ++#[test] ++fn object_used_as_array_element_keeps_its_boundary() { ++ let value = LinoValue::array([ ++ LinoValue::object([ ++ ("id", LinoValue::String("1".to_string())), ++ ("label", LinoValue::String("one".to_string())), ++ ]), ++ LinoValue::object([ ++ ("id", LinoValue::String("2".to_string())), ++ ("label", LinoValue::String("two".to_string())), ++ ]), ++ ]); ++ ++ let decoded = decode(&encode(&value)).unwrap(); ++ assert_eq!(decoded, value); ++ ++ let items = decoded.as_array().expect("array"); ++ assert_eq!(items.len(), 2, "record boundaries were lost: {items:?}"); ++ assert_eq!(items[0].get("label").unwrap().as_str(), Some("one")); ++} ++ ++#[test] ++fn numbers_and_booleans_keep_their_types() { ++ let value = LinoValue::object([ ++ ("int", LinoValue::Int(-7)), ++ ("float", LinoValue::Float(3.5)), ++ ("whole_float", LinoValue::Float(2.0)), ++ ("yes", LinoValue::Bool(true)), ++ ("no", LinoValue::Bool(false)), ++ ("nothing", LinoValue::Null), ++ ("numeric_string", LinoValue::String("18878".to_string())), ++ ("boolean_string", LinoValue::String("true".to_string())), ++ ]); ++ ++ let decoded = decode(&encode(&value)).unwrap(); ++ ++ assert!(matches!(decoded.get("int"), Some(LinoValue::Int(-7)))); ++ assert!(matches!(decoded.get("float"), Some(LinoValue::Float(_)))); ++ assert!(matches!( ++ decoded.get("whole_float"), ++ Some(LinoValue::Float(_)) ++ )); ++ assert!(matches!(decoded.get("yes"), Some(LinoValue::Bool(true)))); ++ assert!(matches!(decoded.get("no"), Some(LinoValue::Bool(false)))); ++ assert!(matches!(decoded.get("nothing"), Some(LinoValue::Null))); ++ assert_eq!( ++ decoded.get("numeric_string").and_then(LinoValue::as_str), ++ Some("18878") ++ ); ++ assert_eq!( ++ decoded.get("boolean_string").and_then(LinoValue::as_str), ++ Some("true") ++ ); ++} ++ ++#[test] ++fn special_floats_roundtrip() { ++ let value = LinoValue::object([ ++ ("nan", LinoValue::Float(f64::NAN)), ++ ("inf", LinoValue::Float(f64::INFINITY)), ++ ("neg_inf", LinoValue::Float(f64::NEG_INFINITY)), ++ ]); ++ ++ let decoded = decode(&encode(&value)).unwrap(); ++ assert!(decoded.get("nan").unwrap().as_float().unwrap().is_nan()); ++ assert_eq!(decoded.get("inf").unwrap().as_float(), Some(f64::INFINITY)); ++ assert_eq!( ++ decoded.get("neg_inf").unwrap().as_float(), ++ Some(f64::NEG_INFINITY) ++ ); ++} ++ ++#[test] ++fn quotes_and_unicode_roundtrip_as_text() { ++ let values = [ ++ "plain", ++ "", ++ "with spaces", ++ "it's", ++ "he said \"hello\"", ++ "both \"kinds\" of 'quotes'", ++ "unicode: 你好世界 🌍", ++ "parens (and) colons: yes", ++ ]; ++ ++ for text in values { ++ let value = LinoValue::object([("message", LinoValue::String(text.to_string()))]); ++ let encoded = encode(&value); ++ assert!(!encoded.contains("base64"), "{text} was encoded: {encoded}"); ++ assert_eq!( ++ decode(&encoded).unwrap(), ++ value, ++ "roundtrip failed for {text:?}: {encoded}" ++ ); ++ } ++} ++ ++#[test] ++fn values_that_cannot_be_written_as_text_are_marked_individually() { ++ let value = LinoValue::object([ ++ ("readable", LinoValue::String("still visible".to_string())), ++ ("multiline", LinoValue::String("line1\nline2".to_string())), ++ ("tabbed", LinoValue::String("a\tb".to_string())), ++ ]); ++ ++ let encoded = encode(&value); ++ ++ // Only the values that need it are encoded; the rest stays readable. ++ assert!(encoded.contains("readable \"still visible\""), "{encoded}"); ++ assert!(encoded.contains("multiline (base64 \""), "{encoded}"); ++ assert!(encoded.contains("tabbed (base64 \""), "{encoded}"); ++ assert_eq!(decode(&encoded).unwrap(), value); ++} ++ ++#[test] ++fn base64_key_is_not_mistaken_for_a_marker() { ++ let value = LinoValue::object([("base64", LinoValue::String("plain text".to_string()))]); ++ assert_eq!(decode(&encode(&value)).unwrap(), value); ++} ++ ++#[test] ++fn empty_containers_keep_their_type() { ++ let value = LinoValue::object([ ++ ("empty_array", LinoValue::Array(vec![])), ++ ("empty_object", LinoValue::Object(vec![])), ++ ]); ++ assert_eq!(decode(&encode(&value)).unwrap(), value); ++} ++ ++#[test] ++fn scalars_at_the_root_roundtrip() { ++ for value in [ ++ LinoValue::Null, ++ LinoValue::Bool(true), ++ LinoValue::Int(42), ++ LinoValue::Float(0.5), ++ LinoValue::String("root".to_string()), ++ LinoValue::String("multi\nline".to_string()), ++ ] { ++ assert_eq!(decode(&encode(&value)).unwrap(), value, "{value:?}"); ++ } ++} ++ ++#[test] ++fn files_written_in_the_previous_base64_form_still_decode() { ++ // A real stored document, as quoted in issue #37. ++ let stored = "(object ((str dHlwZQ==) (str Um91dGVyU3RhdGU=)) ((str c3VidHlwZQ==) (str VG9rZW5TdG9yZQ==)))"; ++ ++ let decoded = decode(stored).unwrap(); ++ assert_eq!( ++ decoded, ++ LinoValue::object([ ++ ("type", LinoValue::String("RouterState".to_string())), ++ ("subtype", LinoValue::String("TokenStore".to_string())), ++ ]) ++ ); ++} ++ ++#[test] ++fn compact_form_is_still_available_and_still_decodes() { ++ let value = router_state(); ++ ++ let compact = encode_compact(&value); ++ assert_eq!( ++ compact.lines().count(), ++ 1, ++ "compact output must be one line" ++ ); ++ assert!(compact.contains("Um91dGVyU3RhdGU="), "{compact}"); ++ assert_eq!(decode(&compact).unwrap(), value); ++ ++ // The obfuscated alias is the same encoder under an explicit name. ++ assert_eq!(encode_obfuscated(&value), compact); ++} ++ ++#[test] ++fn compact_scalars_and_containers_still_decode() { ++ for value in [ ++ LinoValue::Null, ++ LinoValue::Bool(false), ++ LinoValue::Int(-1), ++ LinoValue::Float(f64::INFINITY), ++ LinoValue::String("hello".to_string()), ++ LinoValue::Array(vec![]), ++ LinoValue::Object(vec![]), ++ LinoValue::array([LinoValue::Int(1), LinoValue::String("two".to_string())]), ++ ] { ++ let compact = encode_compact(&value); ++ assert_eq!(decode(&compact).unwrap(), value, "compact: {compact}"); ++ } ++} ++ ++#[test] ++fn hand_written_documents_are_accepted() { ++ let text = "(\n name \"Alice\"\n age 30\n tags (\n \"a\"\n \"b\"\n )\n)"; ++ let decoded = decode(text).unwrap(); ++ ++ assert_eq!( ++ decoded.get("name").and_then(LinoValue::as_str), ++ Some("Alice") ++ ); ++ assert_eq!(decoded.get("age").and_then(LinoValue::as_int), Some(30)); ++ assert_eq!( ++ decoded ++ .get("tags") ++ .and_then(LinoValue::as_array) ++ .unwrap() ++ .len(), ++ 2 ++ ); ++} diff --git a/docs/case-studies/issue-39/data/pr-38.json b/docs/case-studies/issue-39/data/pr-38.json new file mode 100644 index 0000000..dd3e375 --- /dev/null +++ b/docs/case-studies/issue-39/data/pr-38.json @@ -0,0 +1 @@ +{"body":"Closes #37.\n\n## Problem\n\nStored documents were unreadable: every string went through base64, so a router\nstate file looked like\n\n```lino\n(object ((str dHlwZQ==) (str Um91dGVyU3RhdGU=)) ((str c3VidHlwZQ==) (str VG9rZW5TdG9yZQ==)))\n```\n\n`grep`, `git diff` and human review were all useless on it, even though nothing\nin the data actually needed encoding.\n\n## Solution\n\n`encode()` now writes an indented, plain-text document. One `( )` construct\ncarries both objects and arrays at every level including the root; `key value`\nlines make an object, bare-value lines make an array:\n\n```lino\n(\n type \"RouterState\"\n server (\n host \"127.0.0.1\"\n port 18878\n )\n models (\n \"claude-haiku\"\n \"claude-opus\"\n )\n)\n```\n\n- Strings are double-quoted and written as text; numbers, `true`, `false` and\n `null` stay bare, so `\"18878\"` and `18878` still decode to a string and an int\n respectively.\n- A value is base64-encoded only when it genuinely cannot be written as text\n (it contains control characters), and each such value is marked individually\n as `(base64 \"...\")` — the rest of the document stays readable.\n- An empty array is `()` and an empty object is `(` + newline + `)`, so empty\n containers keep their type across a round trip.\n- The previous single-line form is kept under explicit names,\n `encode_compact()` / `encode_obfuscated()`; `encode_with_indent()` lets the\n indentation string be configured. Nothing but the readable form is the default.\n- `decode()` detects which form it is given, so previously written files keep\n decoding and are rewritten in the readable form the next time they are saved.\n- `links-notation` is raised to 0.14, where a parenthesis opens a nested\n indentation context — required for the nested form above to read back.\n\nBecause the `links-notation` AST does not preserve quoting (`\"42\"` and `42` both\nparse to `Ref(\"42\")`) and cannot tell a one-pair object from a two-element\narray, the readable form is read back by a small line-aware tokenizer in\n`rust/src/readable.rs` that keeps both distinctions.\n\n## Reproduction\n\n`experiments/issue-37/parenthesis-indentation` contains the probe used to verify\nthat 0.14 is genuinely required: on 0.13 the nested `server` link flattens to\nfour loose references, on 0.14 it yields two pair-links.\n\nBefore/after output of the codec itself is visible in\n`cargo run --example basic_usage` (the example now prints both the readable and\nthe compact form).\n\n## Tests\n\n`rust/tests/readable_format.rs` (17 tests) covers every case listed in the issue:\n\n- ASCII keys and values appear verbatim, with no base64 in sight\n- output spans multiple indented lines and matches the documented shape exactly\n- the output is valid Links Notation, and `server` parses as one link of two\n pairs (the 0.14-only behaviour)\n- nested objects/arrays round-trip; an object used as an array element keeps its\n record boundary\n- numbers and booleans stay numbers and booleans; numeric- and boolean-looking\n strings stay strings; NaN/Infinity/-Infinity round-trip\n- quotes, apostrophes, parentheses and non-ASCII text round-trip as text\n- values with control characters are marked individually and round-trip, and a\n literal `base64` key is not mistaken for the marker\n- documents written in the previous base64 form (the real string quoted in the\n issue) still decode, and `encode_compact` output still decodes\n- hand-written documents are accepted\n\n`rust/tests/documented_examples.rs` pins the snippets used in the READMEs, and\n`rust/src/readable.rs` carries 9 unit tests for the encoder/decoder internals.\n\nFull local run: 41 unit + 17 readable + 5 documentation + 7 doc tests passing,\nwith `cargo fmt --check`, `cargo clippy --all-targets -D warnings` and\n`scripts/check-file-size.mjs` clean.\n\n## Docs\n\nCrate docs, `rust/README.md` and the root `README.md` no longer claim that UTF-8\nsupport works \"using base64 encoding\"; they describe the readable format, the\noutput-format table (`encode` / `encode_with_indent` / `encode_compact` /\n`encode_obfuscated`) and the `(base64 \"...\")` marker. A `rust/changelog.d/`\nfragment requests a minor bump.\n","commits":[{"authoredDate":"2026-08-20T05:25:16Z","authors":[{"email":"drakonard@gmail.com","id":"MDQ6VXNlcjE0MzE5MDQ=","login":"konard","name":"konard"}],"committedDate":"2026-08-20T05:25:16Z","messageBody":"Adding .gitkeep for PR creation (default mode).\nThis file will be removed when the task is complete.\n\nIssue: https://github.com/link-foundation/lino-objects-codec/issues/37","messageHeadline":"Initial commit with task details","oid":"1b7193a2dcd4c1daf077a36dacd2c179aa90e115"},{"authoredDate":"2026-08-20T05:41:54Z","authors":[{"email":"drakonard@gmail.com","id":"MDQ6VXNlcjE0MzE5MDQ=","login":"konard","name":"konard"}],"committedDate":"2026-08-20T05:41:54Z","messageBody":"encode() now writes an indented, plain-text document that uses one ( )\nconstruct for both objects and arrays at every level, including the root.\nKeys and values are written verbatim, strings are double-quoted, and\nnumbers, true/false and null stay bare so types survive a round trip.\n\nA value is base64-encoded only when it genuinely cannot be written as\ntext (it contains control characters), and each such value is marked\nindividually as (base64 \"...\") while everything around it stays readable.\n\nThe previous single-line base64 output is kept under the explicit names\nencode_compact()/encode_obfuscated(), and decode() accepts both forms, so\nexisting files keep working and migrate on the next write.\n\nReading the readable form back needs links-notation 0.14 semantics, where\na parenthesis opens a nested indentation context, so the dependency is\nraised to 0.14.\n\nCloses #37","messageHeadline":"feat(rust): make readable indented Links Notation the default encoding","oid":"29373e36e8aa7cf7122795894ce4cb817061ba01"}],"createdAt":"2026-08-20T05:25:26Z","files":[{"path":".gitkeep","additions":2,"deletions":1,"changeType":"MODIFIED"},{"path":"README.md","additions":19,"deletions":0,"changeType":"MODIFIED"},{"path":"experiments/issue-37/parenthesis-indentation/.gitignore","additions":2,"deletions":0,"changeType":"ADDED"},{"path":"experiments/issue-37/parenthesis-indentation/Cargo.toml","additions":8,"deletions":0,"changeType":"ADDED"},{"path":"experiments/issue-37/parenthesis-indentation/src/bin/shapes.rs","additions":25,"deletions":0,"changeType":"ADDED"},{"path":"experiments/issue-37/parenthesis-indentation/src/main.rs","additions":24,"deletions":0,"changeType":"ADDED"},{"path":"rust/Cargo.lock","additions":49,"deletions":2,"changeType":"MODIFIED"},{"path":"rust/Cargo.toml","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"rust/README.md","additions":104,"deletions":16,"changeType":"MODIFIED"},{"path":"rust/changelog.d/20260820_120000_readable_default_format.md","additions":14,"deletions":0,"changeType":"ADDED"},{"path":"rust/examples/basic_usage.rs","additions":17,"deletions":5,"changeType":"MODIFIED"},{"path":"rust/src/lib.rs","additions":190,"deletions":8,"changeType":"MODIFIED"},{"path":"rust/src/readable.rs","additions":591,"deletions":0,"changeType":"ADDED"},{"path":"rust/tests/documented_examples.rs","additions":52,"deletions":0,"changeType":"ADDED"},{"path":"rust/tests/readable_format.rs","additions":368,"deletions":0,"changeType":"ADDED"}],"mergeCommit":{"oid":"ea6d05f51396d3211ee54572f3d2e6cd26517246"},"mergedAt":"2026-08-20T05:47:12Z","number":38,"title":"feat(rust): make readable indented Links Notation the default encoding","url":"https://github.com/link-foundation/lino-objects-codec/pull/38"} diff --git a/docs/case-studies/issue-39/data/pr-40.json b/docs/case-studies/issue-39/data/pr-40.json new file mode 100644 index 0000000..693cb76 --- /dev/null +++ b/docs/case-studies/issue-39/data/pr-40.json @@ -0,0 +1 @@ +{"body":"## 🤖 AI-Powered Solution Draft\n\nThis pull request is being automatically generated to solve issue #39.\n\n### 📋 Issue Reference\nFixes #39\n\n### 🚧 Status\n**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.\n\n### 📝 Implementation Details\n_Details will be added as the solution draft is developed..._\n\n---\n*This PR was created automatically by the AI issue solver*","createdAt":"2026-08-20T06:10:16Z","headRefName":"issue-39-e53c893293ed","number":40,"title":"[WIP] Apply it to all languages and docs","url":"https://github.com/link-foundation/lino-objects-codec/pull/40"} diff --git a/js/src/codec.js b/js/src/codec.js index 7a7d80e..e325b60 100644 --- a/js/src/codec.js +++ b/js/src/codec.js @@ -1,8 +1,17 @@ /** * Object encoder/decoder for Links Notation format. + * + * `encode()` writes the readable, indented form documented in `readable.js`: + * keys and values appear as plain text, so a stored document can be read, + * grepped and reviewed directly. The previous single-line, fully base64-encoded + * form stays available under the explicit names `encodeCompact()` / + * `encodeObfuscated()`, and `decode()` accepts both, so documents written by + * earlier versions keep working and migrate on the next write. */ import { Parser, Link } from 'links-notation'; +import * as readable from './readable.js'; +import { trace } from './debug.js'; /** * Codec for encoding/decoding JavaScript objects to/from Links Notation. @@ -74,12 +83,34 @@ export class ObjectCodec { } /** - * Encode a JavaScript object to Links Notation format. + * Encode a JavaScript object to the readable, indented Links Notation format. + * + * This is the default representation: keys and values are written as plain + * text, one per line, so the result can be read and reviewed directly. + * See `readable.js` for the exact shape. + * * @param {Object} options - Options * @param {*} options.obj - The JavaScript object to encode - * @returns {string} String representation in Links Notation format + * @param {string} [options.indent] - Indentation string used per nesting level + * @returns {string} String representation in readable Links Notation format */ encode(options = {}) { + const { obj, indent = readable.DEFAULT_INDENT } = options; + return readable.encode(obj, indent); + } + + /** + * Encode a JavaScript object to the compact, single-line Links Notation format. + * + * Every value is tagged with its type and every string is base64-encoded, so + * the whole document fits on one line and carries no readable text. This was + * the default before the readable format; callers now opt into it explicitly. + * + * @param {Object} options - Options + * @param {*} options.obj - The JavaScript object to encode + * @returns {string} String representation in compact Links Notation format + */ + encodeCompact(options = {}) { const { obj } = options; // Reset state for each encode operation this._encodeMemo = new Map(); @@ -96,14 +127,54 @@ export class ObjectCodec { return link.format(); } + /** + * Encode a JavaScript object to the compact, base64 form. + * + * Alias of {@link ObjectCodec#encodeCompact}, named after what the form does to + * its content: nothing in the output can be read without decoding it. + * + * @param {Object} options - Options + * @param {*} options.obj - The JavaScript object to encode + * @returns {string} String representation in compact Links Notation format + */ + encodeObfuscated(options = {}) { + return this.encodeCompact(options); + } + /** * Decode Links Notation format to a JavaScript object. + * + * Both the readable format and the compact (base64) format are accepted, so + * documents written by earlier versions keep working and migrate on next write. + * * @param {Object} options - Options * @param {string} options.notation - String in Links Notation format * @returns {*} Reconstructed JavaScript object */ decode(options = {}) { const { notation } = options; + + if (notation === undefined || notation === null || notation.trim() === '') { + return null; + } + + if (isCompactNotation(notation)) { + trace('codec.decode', () => 'compact notation detected'); + return this.decodeCompact({ notation }); + } + + trace('codec.decode', () => 'readable notation detected'); + return readable.decode(notation); + } + + /** + * Decode the compact (base64) Links Notation format. + * @param {Object} options - Options + * @param {string} options.notation - String in compact Links Notation format + * @returns {*} Reconstructed JavaScript object + */ + decodeCompact(options = {}) { + const { notation } = options; // Reset memo for each decode operation this._decodeMemo = new Map(); this._allLinks = []; @@ -454,21 +525,116 @@ export class ObjectCodec { } } +/** + * Type markers that can open a compact document. + * + * The set is the union of the markers used by every implementation, so a + * document written by the Python (`None`, `list`, `dict`) or C# (`list`, `dict`) + * codec is recognised here as well. + */ +const COMPACT_TYPE_MARKERS = new Set([ + ObjectCodec.TYPE_NULL, + ObjectCodec.TYPE_UNDEFINED, + ObjectCodec.TYPE_BOOL, + ObjectCodec.TYPE_INT, + ObjectCodec.TYPE_FLOAT, + ObjectCodec.TYPE_STR, + ObjectCodec.TYPE_ARRAY, + ObjectCodec.TYPE_OBJECT, + 'None', + 'list', + 'dict', +]); + +/** + * Whether a document is in the compact (base64) format rather than the readable + * one. The compact format always opens with `(` followed by a type marker, + * optionally preceded by an `obj_N:` definition id. + * + * @param {string} notation - The document to classify + * @returns {boolean} True when the document is in the compact format + */ +export function isCompactNotation(notation) { + const firstLine = notation + .split('\n') + .map((line) => line.trim()) + .find((line) => line.length > 0); + + if (!firstLine || !firstLine.startsWith('(')) { + return false; + } + + const tokens = firstLine + .slice(1) + .split(/[\s()]+/) + .filter((token) => token.length > 0); + + let marker = tokens[0]; + if (marker === undefined) { + return false; + } + + // Skip the `obj_N:` definition id, if present. + if (marker.endsWith(':')) { + if (!marker.startsWith('obj_')) { + return false; + } + marker = tokens[1]; + if (marker === undefined) { + return false; + } + } + + return COMPACT_TYPE_MARKERS.has(marker); +} + // Convenience functions const _defaultCodec = new ObjectCodec(); /** - * Encode a JavaScript object to Links Notation format. + * Encode a JavaScript object to the readable, indented Links Notation format. * @param {Object} options - Options * @param {*} options.obj - The JavaScript object to encode - * @returns {string} String representation in Links Notation format + * @param {string} [options.indent] - Indentation string used per nesting level + * @returns {string} String representation in readable Links Notation format */ export function encode(options = {}) { return _defaultCodec.encode(options); } +/** + * Encode a JavaScript object to the compact, single-line Links Notation format. + * + * Every string is base64-encoded and the whole document is written on one line. + * {@link decode} reads this form as well, so stored documents remain readable by + * the library after switching to the default readable output. + * + * @param {Object} options - Options + * @param {*} options.obj - The JavaScript object to encode + * @returns {string} String representation in compact Links Notation format + */ +export function encodeCompact(options = {}) { + return _defaultCodec.encodeCompact(options); +} + +/** + * Encode a JavaScript object to the compact, base64 form. + * + * Alias of {@link encodeCompact}, named after what the form does to its content. + * + * @param {Object} options - Options + * @param {*} options.obj - The JavaScript object to encode + * @returns {string} String representation in compact Links Notation format + */ +export function encodeObfuscated(options = {}) { + return _defaultCodec.encodeObfuscated(options); +} + /** * Decode Links Notation format to a JavaScript object. + * + * Both the readable format and the compact (base64) format are accepted. + * * @param {Object} options - Options * @param {string} options.notation - String in Links Notation format * @returns {*} Reconstructed JavaScript object @@ -476,3 +642,13 @@ export function encode(options = {}) { export function decode(options = {}) { return _defaultCodec.decode(options); } + +/** + * Decode the compact (base64) Links Notation format. + * @param {Object} options - Options + * @param {string} options.notation - String in compact Links Notation format + * @returns {*} Reconstructed JavaScript object + */ +export function decodeCompact(options = {}) { + return _defaultCodec.decodeCompact(options); +} diff --git a/js/src/debug.js b/js/src/debug.js new file mode 100644 index 0000000..615da5c --- /dev/null +++ b/js/src/debug.js @@ -0,0 +1,56 @@ +/** + * Opt-in tracing for the codec. + * + * Tracing is off by default and writes nothing. It is turned on either by + * setting the `LINO_CODEC_DEBUG` environment variable to a truthy value + * (`1`, `true`, `yes`, `on`) or by calling {@link setDebugEnabled} from code. + * + * The same switch and the same environment variable exist in the Python, Rust + * and C# implementations, so a problem can be traced the same way in every + * language. + * + * @module debug + */ + +/** Name of the environment variable that turns tracing on. */ +export const DEBUG_ENV_VAR = 'LINO_CODEC_DEBUG'; + +const TRUTHY = new Set(['1', 'true', 'yes', 'on']); + +let overridden = null; + +/** + * Whether tracing is currently on. + * @returns {boolean} True when trace messages are emitted + */ +export function isDebugEnabled() { + if (overridden !== null) { + return overridden; + } + const raw = process.env[DEBUG_ENV_VAR]; + return raw !== undefined && TRUTHY.has(raw.trim().toLowerCase()); +} + +/** + * Turn tracing on or off from code, overriding the environment variable. + * @param {boolean|null} enabled - True/false to force, null to follow the environment + */ +export function setDebugEnabled(enabled) { + overridden = enabled === null ? null : Boolean(enabled); +} + +/** + * Emit a trace message when tracing is on. + * + * The message is passed as a function so that building it costs nothing while + * tracing is off, which is the normal case. + * + * @param {string} scope - Where the message comes from, for example `readable.decode` + * @param {() => string} message - Builds the message text + */ +export function trace(scope, message) { + if (!isDebugEnabled()) { + return; + } + process.stderr.write(`[lino-codec] ${scope}: ${message()}\n`); +} diff --git a/js/src/readable.js b/js/src/readable.js new file mode 100644 index 0000000..33696e6 --- /dev/null +++ b/js/src/readable.js @@ -0,0 +1,548 @@ +/** + * Readable, indented Links Notation representation. + * + * This module implements the default output of {@link encode}: a plain-text, + * indented projection where keys and values are written as they are, so the file + * can be read, grepped and reviewed without decoding anything. + * + * # Shape + * + * One construct — `( )` — is used for both objects and arrays, at every level + * including the root. What distinguishes them is the content of the lines: + * `key value` pairs make an object, bare values make an array. + * + * ```text + * ( + * type "RouterState" + * server ( + * host "127.0.0.1" + * port 18878 + * ) + * models ( + * "claude-haiku" + * "claude-opus" + * ) + * ) + * ``` + * + * # Value mapping + * + * | JavaScript value | Readable form | + * | ------------------------------ | ---------------------------------------- | + * | plain object | `( )` with one `key value` pair per line | + * | `Array` | `( )` with one value per line | + * | `string` | quoted, never encoded | + * | `number` / `boolean` / `null` / `undefined` | bare, so the type survives the round trip | + * + * Empty containers keep their type: an empty array is `()` on one line, while an + * empty object is written as `(` and `)` on two lines. + * + * Only values that cannot be written as plain text are encoded: strings holding + * control characters (including newlines and tabs, which line-based tooling and + * CRLF normalisation would corrupt) are marked individually as + * `(base64 "…")` instead of encoding the whole document. + * + * @module readable + */ + +import { trace } from './debug.js'; + +/** Default indentation used by {@link encode}. */ +export const DEFAULT_INDENT = ' '; + +/** Marker used for values that cannot be represented as plain text. */ +export const BASE64_MARKER = 'base64'; + +/** Literals that a bare reference decodes to instead of a string. */ +const BARE_LITERALS = new Map([ + ['null', null], + ['undefined', undefined], + ['true', true], + ['false', false], + ['NaN', NaN], + ['Infinity', Infinity], + ['-Infinity', -Infinity], +]); + +/** Characters that cannot appear in a bare (unquoted) reference. */ +const QUOTE_CHARS = ['"', "'", '`']; + +/** + * Unicode control characters (categories Cc): the only characters that cannot be + * written as plain text, because they break the line structure of the document. + */ +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/; + +/** Characters that force an object key to be quoted. */ +const KEY_NEEDS_QUOTES = /[\s()':`"]/; + +/** + * Encode a value into the readable, indented Links Notation form. + * @param {*} value - The value to encode + * @param {string} [indent] - Indentation string used per nesting level + * @returns {string} The readable Links Notation document + */ +export function encode(value, indent = DEFAULT_INDENT) { + const out = []; + writeValue(value, indent, 0, out); + return out.join(''); +} + +/** + * Decode the readable, indented Links Notation form back into a value. + * @param {string} text - The readable Links Notation document + * @returns {*} The reconstructed value + */ +export function decode(text) { + const tokens = tokenize(text); + trace('readable.decode', () => `${tokens.length} tokens`); + const cursor = new Cursor(tokens); + const rows = cursor.parseRows(true); + + if (cursor.pos < tokens.length) { + throw new SyntaxError("unexpected ')' in readable notation"); + } + + // A document holding a single value (for example `42`) is that value. + if (rows.length === 1 && rows[0].length === 1) { + return nodeToValue(rows[0][0]); + } + + return rowsToValue(rows, true); +} + +// === Encoding === + +function writeValue(value, indent, level, out) { + if (Array.isArray(value)) { + writeRows(value, indent, level, out, (item) => + writeValue(item, indent, level + 1, out) + ); + return; + } + + if (isPlainContainer(value)) { + const entries = Object.entries(value); + if (entries.length === 0) { + // An empty object spans two lines; `()` on one line is an empty array. + out.push('(\n'); + pushIndent(indent, level, out); + out.push(')'); + return; + } + writeRows(entries, indent, level, out, ([key, child]) => { + out.push(formatKey(key)); + out.push(' '); + writeValue(child, indent, level + 1, out); + }); + return; + } + + out.push(formatScalar(value)); +} + +/** + * Write a container as `(`, one indented line per item, then `)`. + * An empty container collapses to `()`, which reads back as an empty array. + * @param {Array} items - Items to write, one per line + * @param {string} indent - Indentation string used per nesting level + * @param {number} level - Current nesting level + * @param {string[]} out - Output chunks, appended in place + * @param {(item: *) => void} writeItem - Writes one item's line content + */ +function writeRows(items, indent, level, out, writeItem) { + if (items.length === 0) { + out.push('()'); + return; + } + + out.push('('); + for (const item of items) { + out.push('\n'); + pushIndent(indent, level + 1, out); + writeItem(item); + } + out.push('\n'); + pushIndent(indent, level, out); + out.push(')'); +} + +function pushIndent(indent, level, out) { + for (let i = 0; i < level; i += 1) { + out.push(indent); + } +} + +/** + * Whether a value is written as an object: a plain object, not a scalar and not + * an array. Class instances are treated the same way as plain objects, which is + * what the compact codec does as well. + * @param {*} value - The value to classify + * @returns {boolean} True when the value is written as `key value` lines + */ +function isPlainContainer(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Format a scalar value. Strings are quoted, everything else stays bare so that + * its type is recoverable when reading the document back. + * @param {*} value - The scalar to format + * @returns {string} The formatted scalar + */ +function formatScalar(value) { + if (value === null) { + return 'null'; + } + if (value === undefined) { + return 'undefined'; + } + if (typeof value === 'boolean') { + return String(value); + } + if (typeof value === 'number') { + return formatNumber(value); + } + if (typeof value === 'string') { + return formatString(value); + } + if (typeof value === 'bigint') { + return value.toString(); + } + throw new TypeError(`Unsupported type: ${typeof value}`); +} + +function formatNumber(value) { + if (Number.isNaN(value)) { + return 'NaN'; + } + if (value === Infinity) { + return 'Infinity'; + } + if (value === -Infinity) { + return '-Infinity'; + } + return String(value); +} + +/** + * Format a string value: quoted plain text, or an individually marked base64 + * payload when the text cannot be written literally. + * @param {string} value - The string to format + * @returns {string} The formatted string + */ +function formatString(value) { + if (needsEncoding(value)) { + const payload = Buffer.from(value, 'utf-8').toString('base64'); + return `(${BASE64_MARKER} ${quote(payload)})`; + } + return quote(value); +} + +/** + * A value can be written as text unless it contains control characters: + * newlines break the line structure and CRLF normalisation would rewrite them. + * @param {string} value - The string to check + * @returns {boolean} True when the string has to be encoded + */ +function needsEncoding(value) { + return CONTROL_CHARACTERS.test(value); +} + +function quote(value) { + if (!value.includes('"')) { + return `"${value}"`; + } + if (!value.includes("'")) { + return `'${value}'`; + } + // Both quote styles are present: double the double quotes, as the parser expects. + return `"${value.replaceAll('"', '""')}"`; +} + +/** + * Format an object key. Keys are bare when they read as plain identifiers. + * @param {string} key - The key to format + * @returns {string} The formatted key + */ +function formatKey(key) { + const plain = + key.length > 0 && + key !== BASE64_MARKER && + !needsEncoding(key) && + !KEY_NEEDS_QUOTES.test(key); + + return plain ? key : formatString(key); +} + +// === Decoding === + +const TOKEN_OPEN = 'open'; +const TOKEN_CLOSE = 'close'; +const TOKEN_NEWLINE = 'newline'; +const TOKEN_REF = 'ref'; + +/** + * Split a document into tokens: parentheses, newlines and references. + * A reference remembers whether it was quoted, which is what distinguishes a + * string from a number when the document is read back. + * @param {string} text - The document to tokenize + * @returns {Array<{kind: string, value?: string, quoted?: boolean}>} The tokens + */ +function tokenize(text) { + const chars = Array.from(text); + const tokens = []; + let i = 0; + + while (i < chars.length) { + const c = chars[i]; + + if (c === '\n') { + tokens.push({ kind: TOKEN_NEWLINE }); + i += 1; + } else if (/\s/.test(c)) { + i += 1; + } else if (c === '(') { + tokens.push({ kind: TOKEN_OPEN }); + i += 1; + } else if (c === ')') { + tokens.push({ kind: TOKEN_CLOSE }); + i += 1; + } else if (QUOTE_CHARS.includes(c)) { + const [value, next] = readQuoted(chars, i, c); + tokens.push({ kind: TOKEN_REF, value, quoted: true }); + i = next; + } else { + const start = i; + while ( + i < chars.length && + !/\s/.test(chars[i]) && + chars[i] !== '(' && + chars[i] !== ')' && + !QUOTE_CHARS.includes(chars[i]) + ) { + i += 1; + } + tokens.push({ + kind: TOKEN_REF, + value: chars.slice(start, i).join(''), + quoted: false, + }); + } + } + + return tokens; +} + +/** + * Read a quoted reference, where a doubled quote character means a literal one. + * @param {string[]} chars - The document characters + * @param {number} start - Index of the opening quote + * @param {string} quoteChar - The quote character used + * @returns {[string, number]} The value and the index after the closing quote + */ +function readQuoted(chars, start, quoteChar) { + let value = ''; + let i = start + 1; + + while (i < chars.length) { + if (chars[i] === quoteChar) { + if (chars[i + 1] === quoteChar) { + value += quoteChar; + i += 2; + continue; + } + return [value, i + 1]; + } + value += chars[i]; + i += 1; + } + + throw new SyntaxError( + `unterminated quoted value starting at character ${start}` + ); +} + +/** Cursor over the token stream, turning tokens into nodes and rows. */ +class Cursor { + constructor(tokens) { + this.tokens = tokens; + this.pos = 0; + } + + /** + * Parse rows until the matching `)` (or the end of input at the top level). + * A row is one line: the values written between two newlines. + * @param {boolean} topLevel - Whether this is the outermost context + * @returns {Array>} The parsed rows + */ + parseRows(topLevel) { + const rows = []; + let row = []; + + while (this.pos < this.tokens.length) { + const token = this.tokens[this.pos]; + + if (token.kind === TOKEN_CLOSE) { + if (topLevel) { + break; + } + this.pos += 1; + if (row.length > 0) { + rows.push(row); + } + return rows; + } + + if (token.kind === TOKEN_NEWLINE) { + this.pos += 1; + if (row.length > 0) { + rows.push(row); + row = []; + } + continue; + } + + row.push(this.parseNode()); + } + + if (!topLevel) { + throw new SyntaxError("unterminated '(' in readable notation"); + } + + if (row.length > 0) { + rows.push(row); + } + return rows; + } + + /** + * Parse a single node: a reference or a parenthesised link. + * @returns {object} The parsed node + */ + parseNode() { + const token = this.tokens[this.pos]; + + if (token.kind === TOKEN_REF) { + this.pos += 1; + return { ref: true, value: token.value, quoted: token.quoted }; + } + + if (token.kind === TOKEN_OPEN) { + this.pos += 1; + const multiline = this.linkIsMultiline(); + const rows = this.parseRows(false); + return { ref: false, rows, multiline }; + } + + throw new SyntaxError('unexpected token in readable notation'); + } + + /** + * Whether the link that just opened spans more than one line, which is what + * tells an empty object (`(\n)`) from an empty array (`()`). + * @returns {boolean} True when a newline appears before the closing `)` + */ + linkIsMultiline() { + for (let i = this.pos; i < this.tokens.length; i += 1) { + if (this.tokens[i].kind === TOKEN_CLOSE) { + return false; + } + if (this.tokens[i].kind === TOKEN_NEWLINE) { + return true; + } + } + return false; + } +} + +function nodeToValue(node) { + return node.ref + ? refToValue(node.value, node.quoted) + : rowsToValue(node.rows, node.multiline); +} + +function rowsToValue(rows, multiline) { + if (rows.length === 0) { + return multiline ? {} : []; + } + + const marked = decodeMarkedValue(rows); + if (marked !== undefined) { + return marked.value; + } + + // `key value` on every line makes an object; anything else is a list of values. + const isObject = rows.every((row) => row.length === 2 && row[0].ref); + + if (isObject) { + const result = {}; + for (const row of rows) { + result[row[0].value] = nodeToValue(row[1]); + } + return result; + } + + const items = []; + for (const row of rows) { + for (const node of row) { + items.push(nodeToValue(node)); + } + } + return items; +} + +/** + * Recognise `(base64 "…")`, the individual marker for values that could not be + * written as text. A quoted `base64` key is an ordinary object key, not a marker. + * @param {Array>} rows - The rows of the link being decoded + * @returns {{value: string}|undefined} The decoded string, wrapped so that an + * empty result is still distinguishable from "not a marker" + */ +function decodeMarkedValue(rows) { + if (rows.length !== 1 || rows[0].length !== 2) { + return undefined; + } + + const [marker, payload] = rows[0]; + if (!marker.ref || marker.quoted || marker.value !== BASE64_MARKER) { + return undefined; + } + if (!payload.ref || !payload.quoted) { + return undefined; + } + + const decoded = Buffer.from(payload.value, 'base64').toString('utf-8'); + return { value: decoded }; +} + +/** + * Convert a reference to a value. Quoted references are always strings; bare + * references keep the type they were written with. + * @param {string} value - The reference text + * @param {boolean} quoted - Whether the reference was quoted + * @returns {*} The reconstructed value + */ +function refToValue(value, quoted) { + if (quoted) { + return value; + } + + if (BARE_LITERALS.has(value)) { + return BARE_LITERALS.get(value); + } + + if (/^[+-]?\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isSafeInteger(parsed)) { + return parsed; + } + return value; + } + + if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(value)) { + return Number(value); + } + + return value; +} diff --git a/python/src/link_notation_objects_codec/__init__.py b/python/src/link_notation_objects_codec/__init__.py index 7206d2f..620e085 100644 --- a/python/src/link_notation_objects_codec/__init__.py +++ b/python/src/link_notation_objects_codec/__init__.py @@ -3,23 +3,46 @@ This library provides serialization and deserialization of Python objects to/from Links Notation format, with support for circular references and complex object graphs. + +:func:`encode` writes the readable, indented format by default; :func:`decode` +reads both that and the compact (base64) format written by earlier versions. """ -from .codec import ObjectCodec, decode, encode +from .codec import ( + ObjectCodec, + decode, + decode_compact, + encode, + encode_compact, + encode_obfuscated, + is_compact_notation, +) +from .debug import DEBUG_ENV_VAR, is_debug_enabled, set_debug_enabled from .format import ( escape_reference, format_indented, parse_indented, unescape_reference, ) +from .readable import BASE64_MARKER, DEFAULT_INDENT, ReadableFormatError -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ "ObjectCodec", "encode", + "encode_compact", + "encode_obfuscated", "decode", + "decode_compact", + "is_compact_notation", "escape_reference", "unescape_reference", "format_indented", "parse_indented", + "DEFAULT_INDENT", + "BASE64_MARKER", + "ReadableFormatError", + "DEBUG_ENV_VAR", + "is_debug_enabled", + "set_debug_enabled", ] diff --git a/python/src/link_notation_objects_codec/codec.py b/python/src/link_notation_objects_codec/codec.py index 4ce055f..725c93c 100644 --- a/python/src/link_notation_objects_codec/codec.py +++ b/python/src/link_notation_objects_codec/codec.py @@ -1,11 +1,83 @@ -"""Object encoder/decoder for Links Notation format.""" +"""Object encoder/decoder for Links Notation format. + +Two output formats are available: + +* :meth:`ObjectCodec.encode` -- the default. A readable, indented document where + keys and values are written as they are, so it can be read and reviewed + directly. See :mod:`link_notation_objects_codec.readable`. +* :meth:`ObjectCodec.encode_compact` -- the previous default. A single line where + every value is type-tagged and every string is base64-encoded. + +:meth:`ObjectCodec.decode` accepts both, so documents written by earlier versions +keep working and migrate to the readable form on the next write. +""" import base64 import math -from typing import Any, Dict, List, Optional, Set, Tuple +import re +from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple from links_notation import Link, Parser +from . import readable +from .debug import trace + +#: Type markers that open a compact document, across all implementations. +#: +#: The languages historically disagreed on three of them -- Python writes +#: ``None``/``list``/``dict`` where JavaScript and Rust write +#: ``null``/``array``/``object`` -- so every implementation accepts the union and +#: can read a compact document written by any of the others. +_COMPACT_TYPE_MARKERS: FrozenSet[str] = frozenset( + { + "null", + "None", + "bool", + "int", + "float", + "str", + "array", + "list", + "object", + "dict", + } +) + + +def is_compact_notation(notation: str) -> bool: + """Whether a document is in the compact (type-tagged, base64) format. + + The check looks at the first non-empty line: a compact document opens with + ``(`` followed by a type marker, optionally preceded by an ``obj_N:`` + definition id. A readable document opens with ``(`` followed by a key, a + value or a newline, so it is not mistaken for a compact one. + + Args: + notation: The document to inspect. + + Returns: + ``True`` when the document should be read by :func:`decode_compact`. + """ + first_line = next((line.strip() for line in notation.splitlines() if line.strip()), None) + if first_line is None or not first_line.startswith("("): + return False + + tokens = [token for token in re.split(r"[\s()]+", first_line[1:]) if token] + if not tokens: + return False + + marker = tokens[0] + + # Skip the ``obj_N:`` definition id, if present. + if marker.endswith(":"): + if not marker[:-1].startswith("obj_"): + return False + if len(tokens) < 2: + return False + marker = tokens[1] + + return marker in _COMPACT_TYPE_MARKERS + class ObjectCodec: """Codec for encoding/decoding Python objects to/from Links Notation.""" @@ -98,9 +170,26 @@ def _find_objects_needing_ids( self._find_objects_needing_ids(key, seen, new_path) self._find_objects_needing_ids(value, seen, new_path) - def encode(self, obj: Any) -> str: + def encode(self, obj: Any, indent: str = readable.DEFAULT_INDENT) -> str: + """ + Encode a Python object to the readable, indented Links Notation format. + + Args: + obj: The Python object to encode + indent: Indentation string used per nesting level (for example ``" "``) + + Returns: + Readable Links Notation document """ - Encode a Python object to Links Notation format. + return readable.encode(obj, indent) + + def encode_compact(self, obj: Any) -> str: + """ + Encode a Python object to the compact, single-line Links Notation format. + + Every value is tagged with its type and every string is base64-encoded, so + the whole document fits on one line and carries no readable text. This was + the default before the readable format; callers now opt into it explicitly. Uses multi-link format to avoid parser bugs with nested self-references. Each self-referenced object is defined at the top level. @@ -109,7 +198,7 @@ def encode(self, obj: Any) -> str: obj: The Python object to encode Returns: - String representation in Links Notation format + String representation in compact Links Notation format """ # Reset state for each encode operation self._encode_memo = {} @@ -139,13 +228,51 @@ def encode(self, obj: Any) -> str: # Single link output return main_link.format() + def encode_obfuscated(self, obj: Any) -> str: + """ + Encode a Python object to the compact format. + + Deprecated alias of :meth:`encode_compact`, kept for callers written + against the earlier name. + + Args: + obj: The Python object to encode + + Returns: + String representation in compact Links Notation format + """ + return self.encode_compact(obj) + def decode(self, notation: str) -> Any: """ Decode Links Notation format to a Python object. + Both the readable format and the compact (base64) format are accepted, so + files written by earlier versions keep working and migrate on next write. + Args: notation: String in Links Notation format + Returns: + Reconstructed Python object + """ + if notation is None or not notation.strip(): + return None + + if is_compact_notation(notation): + trace("codec.decode", lambda: "compact notation detected") + return self.decode_compact(notation) + + trace("codec.decode", lambda: "readable notation detected") + return readable.decode(notation) + + def decode_compact(self, notation: str) -> Any: + """ + Decode the compact (type-tagged, base64) Links Notation format. + + Args: + notation: String in compact Links Notation format + Returns: Reconstructed Python object """ @@ -450,23 +577,62 @@ def _decode_link(self, link: Link) -> Any: _default_codec = ObjectCodec() -def encode(obj: Any) -> str: +def encode(obj: Any, indent: str = readable.DEFAULT_INDENT) -> str: + """ + Encode a Python object to the readable, indented Links Notation format. + + Args: + obj: The Python object to encode + indent: Indentation string used per nesting level + + Returns: + Readable Links Notation document + + Example: + >>> encode({"age": 30}) + '(\n age 30\n)' + """ + return _default_codec.encode(obj, indent) + + +def encode_compact(obj: Any) -> str: + """ + Encode a Python object to the compact, single-line Links Notation format. + + Every string is base64-encoded and the whole document is written on one line. + :func:`decode` reads this form as well, so stored documents remain readable by + the current version. + + Args: + obj: The Python object to encode + + Returns: + String representation in compact Links Notation format + """ + return _default_codec.encode_compact(obj) + + +def encode_obfuscated(obj: Any) -> str: """ - Encode a Python object to Links Notation format. + Encode a Python object to the compact format. + + Deprecated alias of :func:`encode_compact`. Args: obj: The Python object to encode Returns: - String representation in Links Notation format + String representation in compact Links Notation format """ - return _default_codec.encode(obj) + return _default_codec.encode_obfuscated(obj) def decode(notation: str) -> Any: """ Decode Links Notation format to a Python object. + Both the readable format and the compact (base64) format are accepted. + Args: notation: String in Links Notation format @@ -474,3 +640,16 @@ def decode(notation: str) -> Any: Reconstructed Python object """ return _default_codec.decode(notation) + + +def decode_compact(notation: str) -> Any: + """ + Decode the compact (type-tagged, base64) Links Notation format. + + Args: + notation: String in compact Links Notation format + + Returns: + Reconstructed Python object + """ + return _default_codec.decode_compact(notation) diff --git a/python/src/link_notation_objects_codec/debug.py b/python/src/link_notation_objects_codec/debug.py new file mode 100644 index 0000000..182171d --- /dev/null +++ b/python/src/link_notation_objects_codec/debug.py @@ -0,0 +1,54 @@ +"""Opt-in tracing for the codec. + +Tracing is off by default and writes nothing. It is turned on either by setting +the ``LINO_CODEC_DEBUG`` environment variable to a truthy value (``1``, ``true``, +``yes``, ``on``) or by calling :func:`set_debug_enabled` from code. + +The same switch and the same environment variable exist in the JavaScript, Rust +and C# implementations, so a problem can be traced the same way in every +language. +""" + +import os +import sys +from typing import Callable, Optional + +#: Name of the environment variable that turns tracing on. +DEBUG_ENV_VAR = "LINO_CODEC_DEBUG" + +_TRUTHY = frozenset({"1", "true", "yes", "on"}) + +_overridden: Optional[bool] = None + + +def is_debug_enabled() -> bool: + """Return whether tracing is currently on.""" + if _overridden is not None: + return _overridden + raw = os.environ.get(DEBUG_ENV_VAR) + return raw is not None and raw.strip().lower() in _TRUTHY + + +def set_debug_enabled(enabled: Optional[bool]) -> None: + """Turn tracing on or off from code, overriding the environment variable. + + Args: + enabled: ``True``/``False`` to force, ``None`` to follow the environment. + """ + global _overridden + _overridden = None if enabled is None else bool(enabled) + + +def trace(scope: str, message: Callable[[], str]) -> None: + """Emit a trace message when tracing is on. + + The message is passed as a callable so that building it costs nothing while + tracing is off, which is the normal case. + + Args: + scope: Where the message comes from, for example ``readable.decode``. + message: Builds the message text. + """ + if not is_debug_enabled(): + return + print(f"[lino-codec] {scope}: {message()}", file=sys.stderr) diff --git a/python/src/link_notation_objects_codec/readable.py b/python/src/link_notation_objects_codec/readable.py new file mode 100644 index 0000000..8f9fe4b --- /dev/null +++ b/python/src/link_notation_objects_codec/readable.py @@ -0,0 +1,495 @@ +"""Readable, indented Links Notation representation. + +This module implements the default output of :func:`link_notation_objects_codec.encode`: +a plain-text, indented projection where keys and values are written as they are, +so the document can be read, grepped and reviewed without decoding anything. + +Shape +----- + +One construct -- ``( )`` -- is used for both objects and arrays, at every level +including the root. What distinguishes them is the content of the lines: +``key value`` pairs make a dict, bare values make a list:: + + ( + type "RouterState" + server ( + host "127.0.0.1" + port 18878 + ) + models ( + "claude-haiku" + "claude-opus" + ) + ) + +Value mapping +------------- + +============================== ========================================== +Python value Readable form +============================== ========================================== +``dict`` ``( )`` with one ``key value`` pair per line +``list`` / ``tuple`` ``( )`` with one value per line +``str`` quoted, never encoded +``int`` / ``float`` / ``bool`` / ``None`` bare, so the type survives the round trip +============================== ========================================== + +Empty containers keep their type: an empty list is ``()`` on one line, while an +empty dict is written as ``(`` and ``)`` on two lines. + +Only values that cannot be written as plain text are encoded: strings holding +control characters (including newlines and tabs, which line-based tooling and +CRLF normalisation would corrupt) are marked individually as ``(base64 "...")`` +instead of encoding the whole document. +""" + +import base64 +import math +import re +import unicodedata +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + +from .debug import trace + +#: Default indentation used by :func:`encode`. +DEFAULT_INDENT = " " + +#: Marker used for values that cannot be represented as plain text. +BASE64_MARKER = "base64" + +#: Quote characters that open a quoted reference. +_QUOTE_CHARS = ("'", '"', "`") + +#: Characters that force an object key to be quoted. +_KEY_NEEDS_QUOTES = re.compile(r"[\s()':`\"]") + +_INTEGER_PATTERN = re.compile(r"^[+-]?\d+$") +_FLOAT_PATTERN = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$") + + +class ReadableFormatError(ValueError): + """Raised when a readable document cannot be parsed.""" + + +def encode(value: Any, indent: str = DEFAULT_INDENT) -> str: + """Encode a value into the readable, indented Links Notation form. + + Args: + value: The value to encode. + indent: Indentation string used per nesting level. + + Returns: + The readable Links Notation document. + """ + out: List[str] = [] + _write_value(value, indent, 0, out) + return "".join(out) + + +def decode(text: str) -> Any: + """Decode the readable, indented Links Notation form back into a value. + + Args: + text: The readable Links Notation document. + + Returns: + The reconstructed value. + + Raises: + ReadableFormatError: If the document is not well formed. + """ + tokens = _tokenize(text) + trace("readable.decode", lambda: f"{len(tokens)} tokens") + cursor = _Cursor(tokens) + rows = cursor.parse_rows(top_level=True) + + if cursor.pos < len(tokens): + raise ReadableFormatError("unexpected ')' in readable notation") + + # A document holding a single value (for example ``42``) is that value. + if len(rows) == 1 and len(rows[0]) == 1: + return _node_to_value(rows[0][0]) + + return _rows_to_value(rows, multiline=True) + + +# === Encoding === + + +def _write_value(value: Any, indent: str, level: int, out: List[str]) -> None: + if isinstance(value, dict): + items = list(value.items()) + if not items: + # An empty dict spans two lines; ``()`` on one line is an empty list. + out.append("(\n") + _push_indent(indent, level, out) + out.append(")") + return + + def write_pair(pair: Tuple[Any, Any]) -> None: + key, child = pair + out.append(_format_key(key)) + out.append(" ") + _write_value(child, indent, level + 1, out) + + _write_rows(items, indent, level, out, write_pair) + return + + if isinstance(value, (list, tuple, set, frozenset)): + items_seq: Sequence[Any] = list(value) + + def write_item(item: Any) -> None: + _write_value(item, indent, level + 1, out) + + _write_rows(items_seq, indent, level, out, write_item) + return + + out.append(_format_scalar(value)) + + +def _write_rows(items: Sequence[Any], indent: str, level: int, out: List[str], write_item: Any) -> None: + """Write a container as ``(``, one indented line per item, then ``)``. + + An empty container collapses to ``()``, which reads back as an empty list. + """ + if not items: + out.append("()") + return + + out.append("(") + for item in items: + out.append("\n") + _push_indent(indent, level + 1, out) + write_item(item) + out.append("\n") + _push_indent(indent, level, out) + out.append(")") + + +def _push_indent(indent: str, level: int, out: List[str]) -> None: + for _ in range(level): + out.append(indent) + + +def _format_scalar(value: Any) -> str: + """Format a scalar value. + + Strings are quoted, everything else stays bare so that its type is + recoverable when reading the document back. + """ + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return _format_float(value) + if isinstance(value, str): + return _format_string(value) + if isinstance(value, (bytes, bytearray)): + return f"({BASE64_MARKER} {_quote(base64.b64encode(bytes(value)).decode('ascii'))})" + raise TypeError(f"Unsupported type: {type(value).__name__}") + + +def _format_float(value: float) -> str: + if math.isnan(value): + return "NaN" + if math.isinf(value): + return "Infinity" if value > 0 else "-Infinity" + # ``repr`` keeps the decimal point for whole floats (``1.0``), which is what + # tells a float apart from an int when reading the document back. + return repr(value) + + +def _format_string(value: str) -> str: + """Format a string: quoted plain text, or an individually marked payload.""" + if _needs_encoding(value): + payload = base64.b64encode(value.encode("utf-8")).decode("ascii") + return f"({BASE64_MARKER} {_quote(payload)})" + return _quote(value) + + +def _needs_encoding(value: str) -> bool: + """Whether a string has to be encoded rather than written as text. + + A value can be written as text unless it contains control characters: + newlines break the line structure and CRLF normalisation would rewrite them. + """ + return any(unicodedata.category(char) == "Cc" for char in value) + + +def _quote(value: str) -> str: + if '"' not in value: + return f'"{value}"' + if "'" not in value: + return f"'{value}'" + # Both quote styles are present: double the double quotes, as the parser expects. + return '"' + value.replace('"', '""') + '"' + + +def _format_key(key: Any) -> str: + """Format an object key. Keys are bare when they read as plain identifiers. + + The readable form has string keys, like JSON: a non-string key is written as + the text it formats to, and reads back as that text. + """ + if isinstance(key, str): + text = key + elif key is None or isinstance(key, (bool, int, float)): + text = _format_scalar(key) + else: + raise TypeError(f"Unsupported key type: {type(key).__name__}") + + plain = ( + bool(text) + and text != BASE64_MARKER + and not _needs_encoding(text) + and not _KEY_NEEDS_QUOTES.search(text) + ) + return text if plain else _format_string(text) + + +# === Decoding === + +_TOKEN_OPEN = "open" +_TOKEN_CLOSE = "close" +_TOKEN_NEWLINE = "newline" +_TOKEN_REF = "ref" + + +class _Token: + """One token of a readable document.""" + + __slots__ = ("kind", "value", "quoted") + + def __init__(self, kind: str, value: str = "", quoted: bool = False) -> None: + self.kind = kind + self.value = value + self.quoted = quoted + + +class _Node: + """A parsed element: a reference (remembering whether it was quoted, which is + what distinguishes a string from a number) or a link.""" + + __slots__ = ("is_ref", "value", "quoted", "rows", "multiline") + + def __init__( + self, + is_ref: bool, + value: str = "", + quoted: bool = False, + rows: Optional[List[List["_Node"]]] = None, + multiline: bool = False, + ) -> None: + self.is_ref = is_ref + self.value = value + self.quoted = quoted + self.rows = rows if rows is not None else [] + self.multiline = multiline + + +def _tokenize(text: str) -> List[_Token]: + """Split a document into parentheses, newlines and references.""" + tokens: List[_Token] = [] + i = 0 + length = len(text) + + while i < length: + char = text[i] + + if char == "\n": + tokens.append(_Token(_TOKEN_NEWLINE)) + i += 1 + elif char.isspace(): + i += 1 + elif char == "(": + tokens.append(_Token(_TOKEN_OPEN)) + i += 1 + elif char == ")": + tokens.append(_Token(_TOKEN_CLOSE)) + i += 1 + elif char in _QUOTE_CHARS: + value, i = _read_quoted(text, i, char) + tokens.append(_Token(_TOKEN_REF, value, quoted=True)) + else: + start = i + while i < length and not text[i].isspace() and text[i] not in "()" and text[i] not in _QUOTE_CHARS: + i += 1 + tokens.append(_Token(_TOKEN_REF, text[start:i], quoted=False)) + + return tokens + + +def _read_quoted(text: str, start: int, quote_char: str) -> Tuple[str, int]: + """Read a quoted reference, where a doubled quote character means a literal one.""" + parts: List[str] = [] + i = start + 1 + length = len(text) + + while i < length: + if text[i] == quote_char: + if i + 1 < length and text[i + 1] == quote_char: + parts.append(quote_char) + i += 2 + continue + return "".join(parts), i + 1 + parts.append(text[i]) + i += 1 + + raise ReadableFormatError(f"unterminated quoted value starting at character {start}") + + +class _Cursor: + """Cursor over the token stream, turning tokens into nodes and rows.""" + + def __init__(self, tokens: List[_Token]) -> None: + self.tokens = tokens + self.pos = 0 + + def parse_rows(self, top_level: bool) -> List[List[_Node]]: + """Parse rows until the matching ``)`` (or the end of input at the top level). + + A row is one line: the values written between two newlines. + """ + rows: List[List[_Node]] = [] + row: List[_Node] = [] + + while self.pos < len(self.tokens): + token = self.tokens[self.pos] + + if token.kind == _TOKEN_CLOSE: + if top_level: + break + self.pos += 1 + if row: + rows.append(row) + return rows + + if token.kind == _TOKEN_NEWLINE: + self.pos += 1 + if row: + rows.append(row) + row = [] + continue + + row.append(self.parse_node()) + + if not top_level: + raise ReadableFormatError("unterminated '(' in readable notation") + + if row: + rows.append(row) + return rows + + def parse_node(self) -> _Node: + """Parse a single node: a reference or a parenthesised link.""" + token = self.tokens[self.pos] + + if token.kind == _TOKEN_REF: + self.pos += 1 + return _Node(True, token.value, token.quoted) + + if token.kind == _TOKEN_OPEN: + self.pos += 1 + multiline = self._link_is_multiline() + rows = self.parse_rows(top_level=False) + return _Node(False, rows=rows, multiline=multiline) + + raise ReadableFormatError("unexpected token in readable notation") + + def _link_is_multiline(self) -> bool: + """Whether the link that just opened spans more than one line, which is + what tells an empty dict (``(\\n)``) from an empty list (``()``).""" + for token in self.tokens[self.pos :]: + if token.kind == _TOKEN_CLOSE: + return False + if token.kind == _TOKEN_NEWLINE: + return True + return False + + +def _node_to_value(node: _Node) -> Any: + if node.is_ref: + return _ref_to_value(node.value, node.quoted) + return _rows_to_value(node.rows, node.multiline) + + +def _rows_to_value(rows: List[List[_Node]], multiline: bool) -> Any: + if not rows: + return {} if multiline else [] + + marked = _decode_marked_value(rows) + if marked is not None: + return marked[0] + + # ``key value`` on every line makes a dict; anything else is a list of values. + is_dict = all(len(row) == 2 and row[0].is_ref for row in rows) + + if is_dict: + result: Dict[str, Any] = {} + for row in rows: + result[row[0].value] = _node_to_value(row[1]) + return result + + items: List[Any] = [] + for row in rows: + for node in row: + items.append(_node_to_value(node)) + return items + + +def _decode_marked_value(rows: List[List[_Node]]) -> Optional[Tuple[str]]: + """Recognise ``(base64 "...")``, the individual marker for values that could + not be written as text. + + A quoted ``base64`` key is an ordinary dict key, not a marker. The result is + wrapped in a tuple so that an empty string is still distinguishable from + "not a marker". + """ + if len(rows) != 1 or len(rows[0]) != 2: + return None + + marker, payload = rows[0] + if not marker.is_ref or marker.quoted or marker.value != BASE64_MARKER: + return None + if not payload.is_ref or not payload.quoted: + return None + + try: + decoded = base64.b64decode(payload.value, validate=True).decode("utf-8") + except Exception as error: # noqa: BLE001 - reported as a format error + raise ReadableFormatError(f"invalid base64 value: {error}") from error + return (decoded,) + + +def _ref_to_value(value: str, quoted: bool) -> Union[None, bool, int, float, str]: + """Convert a reference to a value. + + Quoted references are always strings; bare references keep the type they were + written with. + """ + if quoted: + return value + + if value == "null": + return None + if value == "true": + return True + if value == "false": + return False + if value == "NaN": + return math.nan + if value == "Infinity": + return math.inf + if value == "-Infinity": + return -math.inf + + if _INTEGER_PATTERN.match(value): + return int(value) + if _FLOAT_PATTERN.match(value): + return float(value) + + return value diff --git a/rust/src/debug.rs b/rust/src/debug.rs new file mode 100644 index 0000000..548c85d --- /dev/null +++ b/rust/src/debug.rs @@ -0,0 +1,80 @@ +//! Opt-in tracing for the codec. +//! +//! Tracing is off by default and writes nothing. It is turned on either by +//! setting the `LINO_CODEC_DEBUG` environment variable to a truthy value +//! (`1`, `true`, `yes`, `on`) or by calling [`set_debug_enabled`] from code. +//! +//! The same switch and the same environment variable exist in the JavaScript, +//! Python and C# implementations, so a problem can be traced the same way in +//! every language. + +use std::sync::atomic::{AtomicU8, Ordering}; + +/// Name of the environment variable that turns tracing on. +pub const DEBUG_ENV_VAR: &str = "LINO_CODEC_DEBUG"; + +const TRUTHY: [&str; 4] = ["1", "true", "yes", "on"]; + +/// `0` = follow the environment, `1` = forced off, `2` = forced on. +static OVERRIDE: AtomicU8 = AtomicU8::new(0); + +/// Whether tracing is currently on. +pub fn is_debug_enabled() -> bool { + match OVERRIDE.load(Ordering::Relaxed) { + 1 => false, + 2 => true, + _ => std::env::var(DEBUG_ENV_VAR) + .is_ok_and(|raw| TRUTHY.contains(&raw.trim().to_ascii_lowercase().as_str())), + } +} + +/// Turn tracing on or off from code, overriding the environment variable. +/// +/// # Arguments +/// +/// * `enabled` - `Some(true)`/`Some(false)` to force, `None` to follow the environment +pub fn set_debug_enabled(enabled: Option) { + let value = match enabled { + None => 0, + Some(false) => 1, + Some(true) => 2, + }; + OVERRIDE.store(value, Ordering::Relaxed); +} + +/// Emit a trace message when tracing is on. +/// +/// The message is built by a closure so that building it costs nothing while +/// tracing is off, which is the normal case. +/// +/// # Arguments +/// +/// * `scope` - Where the message comes from, for example `readable.decode` +/// * `message` - Builds the message text +pub fn trace String>(scope: &str, message: F) { + if !is_debug_enabled() { + return; + } + eprintln!("[lino-codec] {}: {}", scope, message()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn override_turns_tracing_on_and_off() { + set_debug_enabled(Some(true)); + assert!(is_debug_enabled()); + set_debug_enabled(Some(false)); + assert!(!is_debug_enabled()); + set_debug_enabled(None); + } + + #[test] + fn trace_builds_nothing_while_off() { + set_debug_enabled(Some(false)); + trace("test", || panic!("must not be called while tracing is off")); + set_debug_enabled(None); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 7934c39..f7d64a9 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -50,6 +50,7 @@ use links_notation::{parse_lino_to_links, LiNo}; use std::collections::{HashMap, HashSet}; use std::fmt; +pub mod debug; pub mod readable; pub use readable::{BASE64_MARKER, DEFAULT_INDENT}; @@ -702,9 +703,11 @@ impl ObjectCodec { } if is_compact_notation(notation) { + crate::debug::trace("codec.decode", || "compact notation detected".to_string()); return self.decode_compact(notation); } + crate::debug::trace("codec.decode", || "readable notation detected".to_string()); readable::decode(notation) } @@ -923,6 +926,25 @@ impl ObjectCodec { /// Compact output always starts a line with `(` immediately followed by a type /// marker — optionally preceded by an object id, as in `(obj_0: object …)`. /// Readable output never does: its first line is either a lone `(` or a scalar. +/// Type markers that open a compact document, across all implementations. +/// +/// The languages historically disagreed on three of them -- Python writes +/// `None`/`list`/`dict` where JavaScript and Rust write `null`/`array`/`object` +/// -- so every implementation accepts the union and can read a compact document +/// written by any of the others. +const COMPACT_TYPE_MARKERS: [&str; 10] = [ + type_ids::NULL, + "None", + type_ids::BOOL, + type_ids::INT, + type_ids::FLOAT, + type_ids::STR, + type_ids::ARRAY, + "list", + type_ids::OBJECT, + "dict", +]; + fn is_compact_notation(notation: &str) -> bool { let Some(first_line) = notation.lines().map(str::trim).find(|l| !l.is_empty()) else { return false; @@ -951,16 +973,7 @@ fn is_compact_notation(notation: &str) -> bool { marker = next; } - matches!( - marker, - type_ids::NULL - | type_ids::BOOL - | type_ids::INT - | type_ids::FLOAT - | type_ids::STR - | type_ids::ARRAY - | type_ids::OBJECT - ) + COMPACT_TYPE_MARKERS.contains(&marker) } // Global codec instance for convenience functions diff --git a/rust/src/readable.rs b/rust/src/readable.rs index 79af927..6b68f80 100644 --- a/rust/src/readable.rs +++ b/rust/src/readable.rs @@ -41,6 +41,7 @@ //! CRLF normalisation would corrupt) are marked individually as //! `(base64 "…")` instead of encoding the whole document. +use crate::debug::trace; use crate::{CodecError, LinoValue}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; @@ -60,6 +61,7 @@ pub fn encode(value: &LinoValue, indent: &str) -> String { /// Decode the readable, indented Links Notation form back into a value. pub fn decode(text: &str) -> Result { let tokens = tokenize(text)?; + trace("readable.decode", || format!("{} tokens", tokens.len())); let mut cursor = Cursor { tokens, pos: 0 }; let rows = cursor.parse_rows(true)?; From 7d9952bfb89f8efefac8d0f492de75fff735bcc3 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 06:34:42 +0000 Subject: [PATCH 03/12] fix(python): honour the declared ruff configuration and align tool target versions python/.ruff.toml replaced [tool.ruff] in pyproject.toml instead of adding to it, so the declared rule selection was dead and ruff ran its own default set. The stray file is gone, its isort setting moved in with the real package name, and the tool target versions now match requires-python. --- .../data/ci-logs/jobs-25399356073.json | 1 + .../data/ci-logs/jobs-25399356076.json | 1 + .../data/ci-logs/jobs-25399356114.json | 1 + .../data/ci-logs/jobs-25399356119.json | 1 + .../data/ci-logs/jobs-25637974944.json | 1 + .../data/ci-logs/jobs-32336595605.json | 1 + .../data/ci-logs/jobs-32336933162.json | 1 + .../issue-39/data/ci-runs-recent.json | 2 +- js/src/index.js | 23 +++- js/src/readable.js | 63 +++++++-- js/tests/test_circular_references.test.js | 41 +++++- python/.ruff.toml | 5 - python/pyproject.toml | 12 +- .../link_notation_objects_codec/__init__.py | 8 +- .../src/link_notation_objects_codec/codec.py | 36 +++-- .../src/link_notation_objects_codec/debug.py | 6 +- .../src/link_notation_objects_codec/format.py | 15 ++- .../link_notation_objects_codec/readable.py | 124 ++++++++++++------ python/tests/test_circular_references.py | 57 +++++++- .../test_create_github_release_helpers.py | 14 +- python/tests/test_format.py | 12 +- 21 files changed, 309 insertions(+), 116 deletions(-) create mode 100644 docs/case-studies/issue-39/data/ci-logs/jobs-25399356073.json create mode 100644 docs/case-studies/issue-39/data/ci-logs/jobs-25399356076.json create mode 100644 docs/case-studies/issue-39/data/ci-logs/jobs-25399356114.json create mode 100644 docs/case-studies/issue-39/data/ci-logs/jobs-25399356119.json create mode 100644 docs/case-studies/issue-39/data/ci-logs/jobs-25637974944.json create mode 100644 docs/case-studies/issue-39/data/ci-logs/jobs-32336595605.json create mode 100644 docs/case-studies/issue-39/data/ci-logs/jobs-32336933162.json delete mode 100644 python/.ruff.toml diff --git a/docs/case-studies/issue-39/data/ci-logs/jobs-25399356073.json b/docs/case-studies/issue-39/data/ci-logs/jobs-25399356073.json new file mode 100644 index 0000000..203bbba --- /dev/null +++ b/docs/case-studies/issue-39/data/ci-logs/jobs-25399356073.json @@ -0,0 +1 @@ +{"total_count":8,"jobs":[{"id":74494518768,"run_id":25399356073,"workflow_name":"C# CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356073","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgl8A","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494518768","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356073/job/74494518768","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:16Z","completed_at":"2026-05-05T20:04:53Z","name":"Test (.NET on ubuntu-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:17Z","completed_at":"2026-05-05T20:04:19Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:19Z","completed_at":"2026-05-05T20:04:20Z"},{"name":"Setup .NET","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:20Z","completed_at":"2026-05-05T20:04:29Z"},{"name":"Restore dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:29Z","completed_at":"2026-05-05T20:04:35Z"},{"name":"Build","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:35Z","completed_at":"2026-05-05T20:04:41Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:41Z","completed_at":"2026-05-05T20:04:44Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:04:44Z","completed_at":"2026-05-05T20:04:46Z"},{"name":"Upload coverage (Ubuntu only)","status":"completed","conclusion":"success","number":8,"started_at":"2026-05-05T20:04:46Z","completed_at":"2026-05-05T20:04:50Z"},{"name":"Post Setup .NET","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:04:50Z","completed_at":"2026-05-05T20:04:50Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":16,"started_at":"2026-05-05T20:04:50Z","completed_at":"2026-05-05T20:04:50Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":17,"started_at":"2026-05-05T20:04:50Z","completed_at":"2026-05-05T20:04:50Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494518768","labels":["ubuntu-latest"],"runner_id":1000027086,"runner_name":"GitHub Actions 1000027086","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494518784,"run_id":25399356073,"workflow_name":"C# CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356073","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgmAA","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494518784","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356073/job/74494518784","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:17Z","completed_at":"2026-05-05T20:04:36Z","name":"Test (.NET on macos-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:17Z","completed_at":"2026-05-05T20:04:20Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:20Z","completed_at":"2026-05-05T20:04:21Z"},{"name":"Setup .NET","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:21Z","completed_at":"2026-05-05T20:04:22Z"},{"name":"Restore dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:22Z","completed_at":"2026-05-05T20:04:25Z"},{"name":"Build","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:25Z","completed_at":"2026-05-05T20:04:28Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:28Z","completed_at":"2026-05-05T20:04:30Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:04:30Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Upload coverage (Ubuntu only)","status":"completed","conclusion":"skipped","number":8,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Post Setup .NET","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":16,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":17,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:33Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494518784","labels":["macos-latest"],"runner_id":1000027083,"runner_name":"GitHub Actions 1000027083","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494518793,"run_id":25399356073,"workflow_name":"C# CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356073","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgmCQ","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494518793","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356073/job/74494518793","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:22Z","completed_at":"2026-05-05T20:04:57Z","name":"Lint and Format Check","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:23Z","completed_at":"2026-05-05T20:04:24Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:24Z","completed_at":"2026-05-05T20:04:25Z"},{"name":"Setup .NET","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:25Z","completed_at":"2026-05-05T20:04:34Z"},{"name":"Restore dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:34Z","completed_at":"2026-05-05T20:04:40Z"},{"name":"Check formatting","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:40Z","completed_at":"2026-05-05T20:04:50Z"},{"name":"Build with warnings as errors","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:50Z","completed_at":"2026-05-05T20:04:55Z"},{"name":"Post Setup .NET","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-05T20:04:55Z","completed_at":"2026-05-05T20:04:55Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-05-05T20:04:55Z","completed_at":"2026-05-05T20:04:56Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:04:56Z","completed_at":"2026-05-05T20:04:56Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494518793","labels":["ubuntu-latest"],"runner_id":1000027082,"runner_name":"GitHub Actions 1000027082","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494518796,"run_id":25399356073,"workflow_name":"C# CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356073","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgmDA","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494518796","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356073/job/74494518796","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:16Z","completed_at":"2026-05-05T20:05:37Z","name":"Test (.NET on windows-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:17Z","completed_at":"2026-05-05T20:04:19Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:19Z","completed_at":"2026-05-05T20:04:24Z"},{"name":"Setup .NET","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:24Z","completed_at":"2026-05-05T20:04:36Z"},{"name":"Restore dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:36Z","completed_at":"2026-05-05T20:05:06Z"},{"name":"Build","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:05:06Z","completed_at":"2026-05-05T20:05:19Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:05:19Z","completed_at":"2026-05-05T20:05:30Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:05:30Z","completed_at":"2026-05-05T20:05:33Z"},{"name":"Upload coverage (Ubuntu only)","status":"completed","conclusion":"skipped","number":8,"started_at":"2026-05-05T20:05:33Z","completed_at":"2026-05-05T20:05:33Z"},{"name":"Post Setup .NET","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:05:33Z","completed_at":"2026-05-05T20:05:34Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":16,"started_at":"2026-05-05T20:05:34Z","completed_at":"2026-05-05T20:05:35Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":17,"started_at":"2026-05-05T20:05:35Z","completed_at":"2026-05-05T20:05:35Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494518796","labels":["windows-latest"],"runner_id":1000027081,"runner_name":"GitHub Actions 1000027081","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494519774,"run_id":25399356073,"workflow_name":"C# CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356073","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgp3g","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494519774","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356073/job/74494519774","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:14Z","completed_at":"2026-05-05T20:04:13Z","name":"Changeset Check","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494519774","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":74494755650,"run_id":25399356073,"workflow_name":"C# CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356073","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDvDQg","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494755650","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356073/job/74494755650","status":"completed","conclusion":"success","created_at":"2026-05-05T20:05:37Z","started_at":"2026-05-05T20:05:46Z","completed_at":"2026-05-05T20:06:14Z","name":"Build Package","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:05:46Z","completed_at":"2026-05-05T20:05:48Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:05:48Z","completed_at":"2026-05-05T20:05:49Z"},{"name":"Setup .NET","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:05:49Z","completed_at":"2026-05-05T20:05:56Z"},{"name":"Restore dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:05:56Z","completed_at":"2026-05-05T20:06:03Z"},{"name":"Build","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:06:03Z","completed_at":"2026-05-05T20:06:10Z"},{"name":"Pack NuGet package","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:06:10Z","completed_at":"2026-05-05T20:06:11Z"},{"name":"Upload artifacts","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:06:11Z","completed_at":"2026-05-05T20:06:12Z"},{"name":"Post Setup .NET","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:06:12Z","completed_at":"2026-05-05T20:06:12Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-05-05T20:06:12Z","completed_at":"2026-05-05T20:06:12Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:06:12Z","completed_at":"2026-05-05T20:06:12Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494755650","labels":["ubuntu-latest"],"runner_id":1000027101,"runner_name":"GitHub Actions 1000027101","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494862189,"run_id":25399356073,"workflow_name":"C# CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356073","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWD1jbQ","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494862189","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356073/job/74494862189","status":"completed","conclusion":"failure","created_at":"2026-05-05T20:06:14Z","started_at":"2026-05-05T20:06:23Z","completed_at":"2026-05-05T20:06:46Z","name":"Auto Release","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:06:24Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:28Z"},{"name":"Setup .NET","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:06:28Z","completed_at":"2026-05-05T20:06:35Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:06:35Z","completed_at":"2026-05-05T20:06:42Z"},{"name":"Check if version changed","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:06:42Z","completed_at":"2026-05-05T20:06:43Z"},{"name":"Download artifacts","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:06:43Z","completed_at":"2026-05-05T20:06:44Z"},{"name":"Publish to NuGet","status":"completed","conclusion":"failure","number":7,"started_at":"2026-05-05T20:06:44Z","completed_at":"2026-05-05T20:06:44Z"},{"name":"Verify package on NuGet","status":"completed","conclusion":"skipped","number":8,"started_at":"2026-05-05T20:06:44Z","completed_at":"2026-05-05T20:06:44Z"},{"name":"Create GitHub Release","status":"completed","conclusion":"skipped","number":9,"started_at":"2026-05-05T20:06:44Z","completed_at":"2026-05-05T20:06:44Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"skipped","number":16,"started_at":"2026-05-05T20:06:44Z","completed_at":"2026-05-05T20:06:44Z"},{"name":"Post Setup .NET","status":"completed","conclusion":"skipped","number":17,"started_at":"2026-05-05T20:06:44Z","completed_at":"2026-05-05T20:06:44Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":18,"started_at":"2026-05-05T20:06:44Z","completed_at":"2026-05-05T20:06:44Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":19,"started_at":"2026-05-05T20:06:44Z","completed_at":"2026-05-05T20:06:44Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494862189","labels":["ubuntu-latest"],"runner_id":1000027103,"runner_name":"GitHub Actions 1000027103","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494862627,"run_id":25399356073,"workflow_name":"C# CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356073","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWD1lIw","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494862627","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356073/job/74494862627","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:06:14Z","started_at":"2026-05-05T20:06:14Z","completed_at":"2026-05-05T20:06:14Z","name":"Manual Release","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494862627","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null}]} \ No newline at end of file diff --git a/docs/case-studies/issue-39/data/ci-logs/jobs-25399356076.json b/docs/case-studies/issue-39/data/ci-logs/jobs-25399356076.json new file mode 100644 index 0000000..287f12b --- /dev/null +++ b/docs/case-studies/issue-39/data/ci-logs/jobs-25399356076.json @@ -0,0 +1 @@ +{"total_count":6,"jobs":[{"id":74494518903,"run_id":25399356076,"workflow_name":"Python CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356076","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgmdw","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494518903","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356076/job/74494518903","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:16Z","completed_at":"2026-05-05T20:04:34Z","name":"Lint and Format Check","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:16Z","completed_at":"2026-05-05T20:04:17Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:17Z","completed_at":"2026-05-05T20:04:18Z"},{"name":"Setup Python","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:18Z","completed_at":"2026-05-05T20:04:18Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:18Z","completed_at":"2026-05-05T20:04:31Z"},{"name":"Run Ruff linting","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:31Z","completed_at":"2026-05-05T20:04:31Z"},{"name":"Check Ruff formatting","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:31Z","completed_at":"2026-05-05T20:04:31Z"},{"name":"Run mypy","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:04:31Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Check file size limit","status":"completed","conclusion":"success","number":8,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Post Setup Python","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:33Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":16,"started_at":"2026-05-05T20:04:33Z","completed_at":"2026-05-05T20:04:33Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":17,"started_at":"2026-05-05T20:04:33Z","completed_at":"2026-05-05T20:04:33Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494518903","labels":["ubuntu-latest"],"runner_id":1000027087,"runner_name":"GitHub Actions 1000027087","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494518912,"run_id":25399356076,"workflow_name":"Python CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356076","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgmgA","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494518912","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356076/job/74494518912","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:22Z","completed_at":"2026-05-05T20:04:43Z","name":"Test (Python 3.13)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:23Z","completed_at":"2026-05-05T20:04:24Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:24Z","completed_at":"2026-05-05T20:04:25Z"},{"name":"Setup Python","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:25Z","completed_at":"2026-05-05T20:04:25Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:25Z","completed_at":"2026-05-05T20:04:37Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:37Z","completed_at":"2026-05-05T20:04:38Z"},{"name":"Upload coverage to Codecov","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:38Z","completed_at":"2026-05-05T20:04:41Z"},{"name":"Post Setup Python","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-05T20:04:41Z","completed_at":"2026-05-05T20:04:41Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-05-05T20:04:41Z","completed_at":"2026-05-05T20:04:42Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:04:42Z","completed_at":"2026-05-05T20:04:42Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494518912","labels":["ubuntu-latest"],"runner_id":1000027084,"runner_name":"GitHub Actions 1000027084","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494519461,"run_id":25399356076,"workflow_name":"Python CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356076","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgopQ","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494519461","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356076/job/74494519461","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:14Z","completed_at":"2026-05-05T20:04:13Z","name":"Changelog Fragment Check","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494519461","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":74494603305,"run_id":25399356076,"workflow_name":"Python CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356076","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDlwKQ","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494603305","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356076/job/74494603305","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:43Z","started_at":"2026-05-05T20:04:46Z","completed_at":"2026-05-05T20:05:12Z","name":"Build Package","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:47Z","completed_at":"2026-05-05T20:04:49Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:49Z","completed_at":"2026-05-05T20:04:52Z"},{"name":"Setup Python","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:52Z","completed_at":"2026-05-05T20:04:52Z"},{"name":"Install build dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:52Z","completed_at":"2026-05-05T20:05:04Z"},{"name":"Build package","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:05:04Z","completed_at":"2026-05-05T20:05:08Z"},{"name":"Check package","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:05:08Z","completed_at":"2026-05-05T20:05:08Z"},{"name":"Upload artifacts","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:05:08Z","completed_at":"2026-05-05T20:05:10Z"},{"name":"Post Setup Python","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:05:10Z","completed_at":"2026-05-05T20:05:10Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-05-05T20:05:10Z","completed_at":"2026-05-05T20:05:10Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:05:10Z","completed_at":"2026-05-05T20:05:10Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494603305","labels":["ubuntu-latest"],"runner_id":1000027097,"runner_name":"GitHub Actions 1000027097","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494686983,"run_id":25399356076,"workflow_name":"Python CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356076","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDq3Bw","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494686983","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356076/job/74494686983","status":"completed","conclusion":"failure","created_at":"2026-05-05T20:05:13Z","started_at":"2026-05-05T20:05:23Z","completed_at":"2026-05-05T20:05:46Z","name":"Auto Release","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:05:24Z","completed_at":"2026-05-05T20:05:26Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:05:26Z","completed_at":"2026-05-05T20:05:27Z"},{"name":"Setup Python","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:05:27Z","completed_at":"2026-05-05T20:05:28Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:05:28Z","completed_at":"2026-05-05T20:05:35Z"},{"name":"Check if version changed","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:05:35Z","completed_at":"2026-05-05T20:05:36Z"},{"name":"Download artifacts","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:05:36Z","completed_at":"2026-05-05T20:05:36Z"},{"name":"Publish to PyPI","status":"completed","conclusion":"failure","number":7,"started_at":"2026-05-05T20:05:36Z","completed_at":"2026-05-05T20:05:44Z"},{"name":"Verify package on PyPI","status":"completed","conclusion":"skipped","number":8,"started_at":"2026-05-05T20:05:44Z","completed_at":"2026-05-05T20:05:44Z"},{"name":"Create GitHub Release","status":"completed","conclusion":"skipped","number":9,"started_at":"2026-05-05T20:05:44Z","completed_at":"2026-05-05T20:05:44Z"},{"name":"Post Publish to PyPI","status":"completed","conclusion":"success","number":16,"started_at":"2026-05-05T20:05:44Z","completed_at":"2026-05-05T20:05:44Z"},{"name":"Post Setup Python","status":"completed","conclusion":"skipped","number":17,"started_at":"2026-05-05T20:05:44Z","completed_at":"2026-05-05T20:05:44Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":18,"started_at":"2026-05-05T20:05:44Z","completed_at":"2026-05-05T20:05:44Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":19,"started_at":"2026-05-05T20:05:44Z","completed_at":"2026-05-05T20:05:44Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494686983","labels":["ubuntu-latest"],"runner_id":1000027098,"runner_name":"GitHub Actions 1000027098","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494687871,"run_id":25399356076,"workflow_name":"Python CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356076","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDq6fw","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494687871","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356076/job/74494687871","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:05:13Z","started_at":"2026-05-05T20:05:13Z","completed_at":"2026-05-05T20:05:13Z","name":"Manual Release","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494687871","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null}]} \ No newline at end of file diff --git a/docs/case-studies/issue-39/data/ci-logs/jobs-25399356114.json b/docs/case-studies/issue-39/data/ci-logs/jobs-25399356114.json new file mode 100644 index 0000000..985a51c --- /dev/null +++ b/docs/case-studies/issue-39/data/ci-logs/jobs-25399356114.json @@ -0,0 +1 @@ +{"total_count":10,"jobs":[{"id":74494519010,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgm4g","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494519010","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494519010","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:22Z","completed_at":"2026-05-05T20:04:27Z","name":"Detect Changes","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:23Z","completed_at":"2026-05-05T20:04:24Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:24Z","completed_at":"2026-05-05T20:04:24Z"},{"name":"Detect changes","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:24Z","completed_at":"2026-05-05T20:04:25Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:25Z","completed_at":"2026-05-05T20:04:25Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:04:25Z","completed_at":"2026-05-05T20:04:25Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494519010","labels":["ubuntu-latest"],"runner_id":1000027088,"runner_name":"GitHub Actions 1000027088","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494519533,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgo7Q","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494519533","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494519533","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:14Z","completed_at":"2026-05-05T20:04:14Z","name":"Create Changeset PR","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494519533","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":74494519564,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgpDA","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494519564","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494519564","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:14Z","completed_at":"2026-05-05T20:04:14Z","name":"Check for Manual Version Changes","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494519564","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":74494557326,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDi8jg","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494557326","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494557326","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:27Z","started_at":"2026-05-05T20:04:29Z","completed_at":"2026-05-05T20:04:40Z","name":"Lint and Format Check","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:29Z","completed_at":"2026-05-05T20:04:30Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:30Z","completed_at":"2026-05-05T20:04:31Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:31Z","completed_at":"2026-05-05T20:04:31Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:31Z","completed_at":"2026-05-05T20:04:35Z"},{"name":"Run ESLint","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:35Z","completed_at":"2026-05-05T20:04:37Z"},{"name":"Check formatting","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:37Z","completed_at":"2026-05-05T20:04:38Z"},{"name":"Check code duplication","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:04:38Z","completed_at":"2026-05-05T20:04:38Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:04:38Z","completed_at":"2026-05-05T20:04:38Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-05-05T20:04:38Z","completed_at":"2026-05-05T20:04:39Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:04:39Z","completed_at":"2026-05-05T20:04:39Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494557326","labels":["ubuntu-latest"],"runner_id":1000027089,"runner_name":"GitHub Actions 1000027089","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494557573,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDi9hQ","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494557573","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494557573","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:27Z","started_at":"2026-05-05T20:04:30Z","completed_at":"2026-05-05T20:05:25Z","name":"Test (Node.js on windows-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:31Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:36Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:36Z","completed_at":"2026-05-05T20:04:45Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:45Z","completed_at":"2026-05-05T20:05:18Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:05:18Z","completed_at":"2026-05-05T20:05:19Z"},{"name":"Run example","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:05:19Z","completed_at":"2026-05-05T20:05:21Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-05T20:05:21Z","completed_at":"2026-05-05T20:05:21Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-05-05T20:05:21Z","completed_at":"2026-05-05T20:05:23Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:05:23Z","completed_at":"2026-05-05T20:05:23Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494557573","labels":["windows-latest"],"runner_id":1000027092,"runner_name":"GitHub Actions 1000027092","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494557594,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDi9mg","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494557594","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494557594","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:27Z","started_at":"2026-05-05T20:04:30Z","completed_at":"2026-05-05T20:04:42Z","name":"Test (Node.js on macos-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:31Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:34Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:34Z","completed_at":"2026-05-05T20:04:35Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:35Z","completed_at":"2026-05-05T20:04:38Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:38Z","completed_at":"2026-05-05T20:04:39Z"},{"name":"Run example","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:39Z","completed_at":"2026-05-05T20:04:39Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-05T20:04:39Z","completed_at":"2026-05-05T20:04:39Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-05-05T20:04:39Z","completed_at":"2026-05-05T20:04:40Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:04:40Z","completed_at":"2026-05-05T20:04:40Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494557594","labels":["macos-latest"],"runner_id":1000027090,"runner_name":"GitHub Actions 1000027090","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494557639,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDi9xw","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494557639","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494557639","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:27Z","started_at":"2026-05-05T20:04:30Z","completed_at":"2026-05-05T20:04:43Z","name":"Test (Node.js on ubuntu-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:31Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:32Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:32Z","completed_at":"2026-05-05T20:04:36Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:36Z","completed_at":"2026-05-05T20:04:41Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:41Z","completed_at":"2026-05-05T20:04:42Z"},{"name":"Run example","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:42Z","completed_at":"2026-05-05T20:04:42Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-05T20:04:42Z","completed_at":"2026-05-05T20:04:42Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-05-05T20:04:42Z","completed_at":"2026-05-05T20:04:42Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:04:42Z","completed_at":"2026-05-05T20:04:42Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494557639","labels":["ubuntu-latest"],"runner_id":1000027091,"runner_name":"GitHub Actions 1000027091","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494558092,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDi_jA","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494558092","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494558092","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:04:27Z","started_at":"2026-05-05T20:04:27Z","completed_at":"2026-05-05T20:04:27Z","name":"Check for Changesets","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494558092","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":74494722126,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDtATg","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494722126","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494722126","status":"completed","conclusion":"success","created_at":"2026-05-05T20:05:26Z","started_at":"2026-05-05T20:05:28Z","completed_at":"2026-05-05T20:06:26Z","name":"Release","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:05:28Z","completed_at":"2026-05-05T20:05:29Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:05:29Z","completed_at":"2026-05-05T20:05:30Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:05:30Z","completed_at":"2026-05-05T20:05:31Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:05:31Z","completed_at":"2026-05-05T20:05:35Z"},{"name":"Update npm for OIDC trusted publishing","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:05:35Z","completed_at":"2026-05-05T20:05:43Z"},{"name":"Check for changesets","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:05:43Z","completed_at":"2026-05-05T20:05:43Z"},{"name":"Merge multiple changesets","status":"completed","conclusion":"skipped","number":7,"started_at":"2026-05-05T20:05:43Z","completed_at":"2026-05-05T20:05:43Z"},{"name":"Version packages and commit to main","status":"completed","conclusion":"success","number":8,"started_at":"2026-05-05T20:05:43Z","completed_at":"2026-05-05T20:05:49Z"},{"name":"Publish to npm","status":"completed","conclusion":"success","number":9,"started_at":"2026-05-05T20:05:49Z","completed_at":"2026-05-05T20:06:17Z"},{"name":"Create GitHub Release","status":"completed","conclusion":"success","number":10,"started_at":"2026-05-05T20:06:17Z","completed_at":"2026-05-05T20:06:20Z"},{"name":"Format GitHub release notes","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-05T20:06:20Z","completed_at":"2026-05-05T20:06:24Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":21,"started_at":"2026-05-05T20:06:24Z","completed_at":"2026-05-05T20:06:24Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":22,"started_at":"2026-05-05T20:06:24Z","completed_at":"2026-05-05T20:06:24Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":23,"started_at":"2026-05-05T20:06:24Z","completed_at":"2026-05-05T20:06:24Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494722126","labels":["ubuntu-latest"],"runner_id":1000027099,"runner_name":"GitHub Actions 1000027099","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494722706,"run_id":25399356114,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356114","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDtCkg","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494722706","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356114/job/74494722706","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:05:26Z","started_at":"2026-05-05T20:05:26Z","completed_at":"2026-05-05T20:05:26Z","name":"Instant Release","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494722706","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null}]} \ No newline at end of file diff --git a/docs/case-studies/issue-39/data/ci-logs/jobs-25399356119.json b/docs/case-studies/issue-39/data/ci-logs/jobs-25399356119.json new file mode 100644 index 0000000..4cd5bfd --- /dev/null +++ b/docs/case-studies/issue-39/data/ci-logs/jobs-25399356119.json @@ -0,0 +1 @@ +{"total_count":11,"jobs":[{"id":74494518913,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgmgQ","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494518913","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494518913","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:22Z","completed_at":"2026-05-05T20:04:31Z","name":"Detect Changes","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:23Z","completed_at":"2026-05-05T20:04:24Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:24Z","completed_at":"2026-05-05T20:04:26Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:26Z","completed_at":"2026-05-05T20:04:26Z"},{"name":"Detect changes","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:26Z","completed_at":"2026-05-05T20:04:26Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:04:26Z","completed_at":"2026-05-05T20:04:27Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":8,"started_at":"2026-05-05T20:04:27Z","completed_at":"2026-05-05T20:04:27Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":9,"started_at":"2026-05-05T20:04:27Z","completed_at":"2026-05-05T20:04:27Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494518913","labels":["ubuntu-latest"],"runner_id":1000027085,"runner_name":"GitHub Actions 1000027085","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494519427,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgogw","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494519427","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494519427","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:14Z","completed_at":"2026-05-05T20:04:13Z","name":"Version Modification Check","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494519427","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":74494519623,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDgpRw","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494519623","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494519623","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:04:14Z","started_at":"2026-05-05T20:04:14Z","completed_at":"2026-05-05T20:04:13Z","name":"Create Changelog PR","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494519623","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":74494568121,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDjmuQ","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494568121","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494568121","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:31Z","started_at":"2026-05-05T20:04:33Z","completed_at":"2026-05-05T20:04:55Z","name":"Lint and Format Check","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:34Z","completed_at":"2026-05-05T20:04:36Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:36Z","completed_at":"2026-05-05T20:04:37Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:37Z","completed_at":"2026-05-05T20:04:47Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:47Z","completed_at":"2026-05-05T20:04:48Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:48Z","completed_at":"2026-05-05T20:04:49Z"},{"name":"Check formatting","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:49Z","completed_at":"2026-05-05T20:04:49Z"},{"name":"Run clippy","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:04:49Z","completed_at":"2026-05-05T20:04:52Z"},{"name":"Check file size limit","status":"completed","conclusion":"success","number":8,"started_at":"2026-05-05T20:04:52Z","completed_at":"2026-05-05T20:04:52Z"},{"name":"Run CI script tests","status":"completed","conclusion":"success","number":9,"started_at":"2026-05-05T20:04:52Z","completed_at":"2026-05-05T20:04:52Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":16,"started_at":"2026-05-05T20:04:52Z","completed_at":"2026-05-05T20:04:53Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":17,"started_at":"2026-05-05T20:04:53Z","completed_at":"2026-05-05T20:04:53Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":18,"started_at":"2026-05-05T20:04:53Z","completed_at":"2026-05-05T20:04:53Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":19,"started_at":"2026-05-05T20:04:53Z","completed_at":"2026-05-05T20:04:53Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494568121","labels":["ubuntu-latest"],"runner_id":1000027093,"runner_name":"GitHub Actions 1000027093","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494568320,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDjngA","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494568320","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494568320","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:04:31Z","started_at":"2026-05-05T20:04:31Z","completed_at":"2026-05-05T20:04:31Z","name":"Changelog Fragment Check","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494568320","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":74494568365,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDjnrQ","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494568365","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494568365","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:31Z","started_at":"2026-05-05T20:04:33Z","completed_at":"2026-05-05T20:04:51Z","name":"Test (Rust on ubuntu-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:34Z","completed_at":"2026-05-05T20:04:34Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:34Z","completed_at":"2026-05-05T20:04:35Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:35Z","completed_at":"2026-05-05T20:04:44Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:44Z","completed_at":"2026-05-05T20:04:44Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:44Z","completed_at":"2026-05-05T20:04:48Z"},{"name":"Run doc tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:48Z","completed_at":"2026-05-05T20:04:48Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:04:48Z","completed_at":"2026-05-05T20:04:48Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:04:48Z","completed_at":"2026-05-05T20:04:49Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-05-05T20:04:49Z","completed_at":"2026-05-05T20:04:50Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:04:50Z","completed_at":"2026-05-05T20:04:50Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494568365","labels":["ubuntu-latest"],"runner_id":1000027094,"runner_name":"GitHub Actions 1000027094","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494568423,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDjn5w","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494568423","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494568423","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:31Z","started_at":"2026-05-05T20:04:34Z","completed_at":"2026-05-05T20:04:49Z","name":"Test (Rust on macos-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:34Z","completed_at":"2026-05-05T20:04:36Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:36Z","completed_at":"2026-05-05T20:04:37Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:37Z","completed_at":"2026-05-05T20:04:39Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:39Z","completed_at":"2026-05-05T20:04:39Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:39Z","completed_at":"2026-05-05T20:04:45Z"},{"name":"Run doc tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:04:45Z","completed_at":"2026-05-05T20:04:45Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:04:45Z","completed_at":"2026-05-05T20:04:45Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:04:45Z","completed_at":"2026-05-05T20:04:46Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-05-05T20:04:46Z","completed_at":"2026-05-05T20:04:47Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:04:47Z","completed_at":"2026-05-05T20:04:47Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494568423","labels":["macos-latest"],"runner_id":1000027096,"runner_name":"GitHub Actions 1000027096","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494568482,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDjoIg","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494568482","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494568482","status":"completed","conclusion":"success","created_at":"2026-05-05T20:04:31Z","started_at":"2026-05-05T20:04:33Z","completed_at":"2026-05-05T20:05:33Z","name":"Test (Rust on windows-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:04:34Z","completed_at":"2026-05-05T20:04:35Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:04:35Z","completed_at":"2026-05-05T20:04:40Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:04:40Z","completed_at":"2026-05-05T20:04:45Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:04:45Z","completed_at":"2026-05-05T20:04:45Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:04:45Z","completed_at":"2026-05-05T20:05:09Z"},{"name":"Run doc tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:05:09Z","completed_at":"2026-05-05T20:05:10Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:05:11Z","completed_at":"2026-05-05T20:05:11Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:05:11Z","completed_at":"2026-05-05T20:05:29Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-05-05T20:05:29Z","completed_at":"2026-05-05T20:05:31Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-05T20:05:31Z","completed_at":"2026-05-05T20:05:31Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494568482","labels":["windows-latest"],"runner_id":1000027095,"runner_name":"GitHub Actions 1000027095","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494743262,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDuS3g","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494743262","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494743262","status":"completed","conclusion":"success","created_at":"2026-05-05T20:05:33Z","started_at":"2026-05-05T20:05:42Z","completed_at":"2026-05-05T20:06:03Z","name":"Build Package","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:05:43Z","completed_at":"2026-05-05T20:05:44Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:05:44Z","completed_at":"2026-05-05T20:05:45Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:05:45Z","completed_at":"2026-05-05T20:05:56Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:05:56Z","completed_at":"2026-05-05T20:05:56Z"},{"name":"Build release","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:05:56Z","completed_at":"2026-05-05T20:06:00Z"},{"name":"Package crate","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:06:00Z","completed_at":"2026-05-05T20:06:00Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-05T20:06:00Z","completed_at":"2026-05-05T20:06:01Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-05-05T20:06:01Z","completed_at":"2026-05-05T20:06:01Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-05T20:06:01Z","completed_at":"2026-05-05T20:06:01Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494743262","labels":["ubuntu-latest"],"runner_id":1000027100,"runner_name":"GitHub Actions 1000027100","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494827528,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDzcCA","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494827528","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494827528","status":"completed","conclusion":"success","created_at":"2026-05-05T20:06:03Z","started_at":"2026-05-05T20:06:13Z","completed_at":"2026-05-05T20:06:31Z","name":"Auto Release","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-05T20:06:13Z","completed_at":"2026-05-05T20:06:15Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-05T20:06:15Z","completed_at":"2026-05-05T20:06:16Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-05T20:06:16Z","completed_at":"2026-05-05T20:06:25Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-05T20:06:25Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Configure git","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Determine bump type from changelog fragments","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Check if release is needed","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Collect changelog and bump version","status":"completed","conclusion":"skipped","number":8,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Get current version","status":"completed","conclusion":"skipped","number":9,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Build release","status":"completed","conclusion":"skipped","number":10,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Publish to crates.io","status":"completed","conclusion":"skipped","number":11,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Create GitHub Release","status":"completed","conclusion":"skipped","number":12,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:26Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":23,"started_at":"2026-05-05T20:06:26Z","completed_at":"2026-05-05T20:06:27Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":24,"started_at":"2026-05-05T20:06:27Z","completed_at":"2026-05-05T20:06:27Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":25,"started_at":"2026-05-05T20:06:27Z","completed_at":"2026-05-05T20:06:27Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494827528","labels":["ubuntu-latest"],"runner_id":1000027102,"runner_name":"GitHub Actions 1000027102","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":74494828647,"run_id":25399356119,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25399356119","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARWDzgZw","head_sha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/74494828647","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25399356119/job/74494828647","status":"completed","conclusion":"skipped","created_at":"2026-05-05T20:06:04Z","started_at":"2026-05-05T20:06:04Z","completed_at":"2026-05-05T20:06:03Z","name":"Instant Release","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/74494828647","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null}]} \ No newline at end of file diff --git a/docs/case-studies/issue-39/data/ci-logs/jobs-25637974944.json b/docs/case-studies/issue-39/data/ci-logs/jobs-25637974944.json new file mode 100644 index 0000000..a257f7f --- /dev/null +++ b/docs/case-studies/issue-39/data/ci-logs/jobs-25637974944.json @@ -0,0 +1 @@ +{"total_count":10,"jobs":[{"id":75253195395,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXCigw","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253195395","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253195395","status":"completed","conclusion":"success","created_at":"2026-05-10T19:42:07Z","started_at":"2026-05-10T19:42:09Z","completed_at":"2026-05-10T19:42:15Z","name":"Detect Changes","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-10T19:42:09Z","completed_at":"2026-05-10T19:42:10Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-10T19:42:10Z","completed_at":"2026-05-10T19:42:12Z"},{"name":"Detect changes","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-10T19:42:12Z","completed_at":"2026-05-10T19:42:12Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-10T19:42:12Z","completed_at":"2026-05-10T19:42:13Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-10T19:42:13Z","completed_at":"2026-05-10T19:42:13Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253195395","labels":["ubuntu-latest"],"runner_id":1000028675,"runner_name":"GitHub Actions 1000028675","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":75253195588,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXCjRA","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253195588","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253195588","status":"completed","conclusion":"skipped","created_at":"2026-05-10T19:42:07Z","started_at":"2026-05-10T19:42:07Z","completed_at":"2026-05-10T19:42:07Z","name":"Create Changeset PR","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253195588","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":75253195609,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXCjWQ","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253195609","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253195609","status":"completed","conclusion":"skipped","created_at":"2026-05-10T19:42:07Z","started_at":"2026-05-10T19:42:07Z","completed_at":"2026-05-10T19:42:07Z","name":"Check for Manual Version Changes","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253195609","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":75253204634,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXDGmg","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253204634","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253204634","status":"completed","conclusion":"success","created_at":"2026-05-10T19:42:16Z","started_at":"2026-05-10T19:42:24Z","completed_at":"2026-05-10T19:42:36Z","name":"Lint and Format Check","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-10T19:42:24Z","completed_at":"2026-05-10T19:42:25Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-10T19:42:25Z","completed_at":"2026-05-10T19:42:26Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-10T19:42:26Z","completed_at":"2026-05-10T19:42:26Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-10T19:42:26Z","completed_at":"2026-05-10T19:42:30Z"},{"name":"Run ESLint","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-10T19:42:30Z","completed_at":"2026-05-10T19:42:32Z"},{"name":"Check formatting","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-10T19:42:32Z","completed_at":"2026-05-10T19:42:33Z"},{"name":"Check code duplication","status":"completed","conclusion":"success","number":7,"started_at":"2026-05-10T19:42:33Z","completed_at":"2026-05-10T19:42:33Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-10T19:42:33Z","completed_at":"2026-05-10T19:42:34Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-05-10T19:42:34Z","completed_at":"2026-05-10T19:42:34Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-05-10T19:42:34Z","completed_at":"2026-05-10T19:42:34Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253204634","labels":["ubuntu-latest"],"runner_id":1000028676,"runner_name":"GitHub Actions 1000028676","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":75253204720,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXDG8A","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253204720","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253204720","status":"completed","conclusion":"success","created_at":"2026-05-10T19:42:16Z","started_at":"2026-05-10T19:42:17Z","completed_at":"2026-05-10T19:42:29Z","name":"Test (Node.js on ubuntu-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-10T19:42:18Z","completed_at":"2026-05-10T19:42:19Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-10T19:42:19Z","completed_at":"2026-05-10T19:42:19Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-10T19:42:19Z","completed_at":"2026-05-10T19:42:22Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-10T19:42:22Z","completed_at":"2026-05-10T19:42:26Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-10T19:42:26Z","completed_at":"2026-05-10T19:42:27Z"},{"name":"Run example","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-10T19:42:27Z","completed_at":"2026-05-10T19:42:27Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-10T19:42:27Z","completed_at":"2026-05-10T19:42:27Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-05-10T19:42:27Z","completed_at":"2026-05-10T19:42:27Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-10T19:42:27Z","completed_at":"2026-05-10T19:42:27Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253204720","labels":["ubuntu-latest"],"runner_id":1000028679,"runner_name":"GitHub Actions 1000028679","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":75253204725,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXDG9Q","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253204725","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253204725","status":"completed","conclusion":"success","created_at":"2026-05-10T19:42:16Z","started_at":"2026-05-10T19:42:17Z","completed_at":"2026-05-10T19:42:52Z","name":"Test (Node.js on windows-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-10T19:42:18Z","completed_at":"2026-05-10T19:42:19Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-10T19:42:19Z","completed_at":"2026-05-10T19:42:24Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-10T19:42:24Z","completed_at":"2026-05-10T19:42:29Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-10T19:42:29Z","completed_at":"2026-05-10T19:42:45Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-10T19:42:45Z","completed_at":"2026-05-10T19:42:46Z"},{"name":"Run example","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-10T19:42:46Z","completed_at":"2026-05-10T19:42:48Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-10T19:42:48Z","completed_at":"2026-05-10T19:42:48Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-05-10T19:42:48Z","completed_at":"2026-05-10T19:42:50Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-10T19:42:50Z","completed_at":"2026-05-10T19:42:50Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253204725","labels":["windows-latest"],"runner_id":1000028677,"runner_name":"GitHub Actions 1000028677","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":75253204742,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXDHBg","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253204742","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253204742","status":"completed","conclusion":"success","created_at":"2026-05-10T19:42:16Z","started_at":"2026-05-10T19:42:17Z","completed_at":"2026-05-10T19:42:25Z","name":"Test (Node.js on macos-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-10T19:42:17Z","completed_at":"2026-05-10T19:42:17Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-10T19:42:17Z","completed_at":"2026-05-10T19:42:19Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-10T19:42:19Z","completed_at":"2026-05-10T19:42:20Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-10T19:42:20Z","completed_at":"2026-05-10T19:42:22Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-10T19:42:22Z","completed_at":"2026-05-10T19:42:23Z"},{"name":"Run example","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-10T19:42:23Z","completed_at":"2026-05-10T19:42:23Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-10T19:42:23Z","completed_at":"2026-05-10T19:42:23Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-05-10T19:42:23Z","completed_at":"2026-05-10T19:42:23Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-05-10T19:42:23Z","completed_at":"2026-05-10T19:42:23Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253204742","labels":["macos-latest"],"runner_id":1000028678,"runner_name":"GitHub Actions 1000028678","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":75253204885,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXDHlQ","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253204885","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253204885","status":"completed","conclusion":"skipped","created_at":"2026-05-10T19:42:16Z","started_at":"2026-05-10T19:42:16Z","completed_at":"2026-05-10T19:42:15Z","name":"Check for Changesets","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253204885","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":75253238009,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXFI-Q","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253238009","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253238009","status":"completed","conclusion":"success","created_at":"2026-05-10T19:42:52Z","started_at":"2026-05-10T19:43:01Z","completed_at":"2026-05-10T19:44:00Z","name":"Release","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-05-10T19:43:02Z","completed_at":"2026-05-10T19:43:03Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-05-10T19:43:03Z","completed_at":"2026-05-10T19:43:04Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-05-10T19:43:04Z","completed_at":"2026-05-10T19:43:07Z"},{"name":"Install dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-05-10T19:43:07Z","completed_at":"2026-05-10T19:43:12Z"},{"name":"Update npm for OIDC trusted publishing","status":"completed","conclusion":"success","number":5,"started_at":"2026-05-10T19:43:12Z","completed_at":"2026-05-10T19:43:20Z"},{"name":"Check for changesets","status":"completed","conclusion":"success","number":6,"started_at":"2026-05-10T19:43:20Z","completed_at":"2026-05-10T19:43:20Z"},{"name":"Merge multiple changesets","status":"completed","conclusion":"skipped","number":7,"started_at":"2026-05-10T19:43:20Z","completed_at":"2026-05-10T19:43:20Z"},{"name":"Version packages and commit to main","status":"completed","conclusion":"success","number":8,"started_at":"2026-05-10T19:43:20Z","completed_at":"2026-05-10T19:43:25Z"},{"name":"Publish to npm","status":"completed","conclusion":"success","number":9,"started_at":"2026-05-10T19:43:25Z","completed_at":"2026-05-10T19:43:54Z"},{"name":"Create GitHub Release","status":"completed","conclusion":"success","number":10,"started_at":"2026-05-10T19:43:54Z","completed_at":"2026-05-10T19:43:55Z"},{"name":"Format GitHub release notes","status":"completed","conclusion":"success","number":11,"started_at":"2026-05-10T19:43:55Z","completed_at":"2026-05-10T19:43:58Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":21,"started_at":"2026-05-10T19:43:58Z","completed_at":"2026-05-10T19:43:59Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":22,"started_at":"2026-05-10T19:43:59Z","completed_at":"2026-05-10T19:43:59Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":23,"started_at":"2026-05-10T19:43:59Z","completed_at":"2026-05-10T19:43:59Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253238009","labels":["ubuntu-latest"],"runner_id":1000028680,"runner_name":"GitHub Actions 1000028680","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":75253238194,"run_id":25637974944,"workflow_name":"JavaScript CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/25637974944","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAARhXFJsg","head_sha":"0529f91279da52f6e6b475cb2169b04edbd431ea","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/75253238194","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/25637974944/job/75253238194","status":"completed","conclusion":"skipped","created_at":"2026-05-10T19:42:52Z","started_at":"2026-05-10T19:42:52Z","completed_at":"2026-05-10T19:42:52Z","name":"Instant Release","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/75253238194","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null}]} \ No newline at end of file diff --git a/docs/case-studies/issue-39/data/ci-logs/jobs-32336595605.json b/docs/case-studies/issue-39/data/ci-logs/jobs-32336595605.json new file mode 100644 index 0000000..40ebb9e --- /dev/null +++ b/docs/case-studies/issue-39/data/ci-logs/jobs-32336595605.json @@ -0,0 +1 @@ +{"total_count":11,"jobs":[{"id":96327259428,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbY1VJA","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327259428","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327259428","status":"completed","conclusion":"success","created_at":"2026-08-20T05:42:08Z","started_at":"2026-08-20T05:42:10Z","completed_at":"2026-08-20T05:42:17Z","name":"Version Modification Check","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:42:11Z","completed_at":"2026-08-20T05:42:11Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:42:11Z","completed_at":"2026-08-20T05:42:13Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:42:13Z","completed_at":"2026-08-20T05:42:13Z"},{"name":"Check for manual version changes","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:42:13Z","completed_at":"2026-08-20T05:42:14Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:42:14Z","completed_at":"2026-08-20T05:42:14Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":8,"started_at":"2026-08-20T05:42:14Z","completed_at":"2026-08-20T05:42:14Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":9,"started_at":"2026-08-20T05:42:14Z","completed_at":"2026-08-20T05:42:14Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327259428","labels":["ubuntu-latest"],"runner_id":1000044565,"runner_name":"GitHub Actions 1000044565","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96327259568,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbY1VsA","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327259568","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327259568","status":"completed","conclusion":"success","created_at":"2026-08-20T05:42:08Z","started_at":"2026-08-20T05:42:10Z","completed_at":"2026-08-20T05:42:19Z","name":"Detect Changes","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:42:12Z","completed_at":"2026-08-20T05:42:12Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:42:12Z","completed_at":"2026-08-20T05:42:14Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:42:14Z","completed_at":"2026-08-20T05:42:17Z"},{"name":"Detect changes","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:42:17Z","completed_at":"2026-08-20T05:42:17Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:42:17Z","completed_at":"2026-08-20T05:42:17Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":8,"started_at":"2026-08-20T05:42:17Z","completed_at":"2026-08-20T05:42:18Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":9,"started_at":"2026-08-20T05:42:18Z","completed_at":"2026-08-20T05:42:18Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327259568","labels":["ubuntu-latest"],"runner_id":1000044566,"runner_name":"GitHub Actions 1000044566","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96327260207,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbY1YLw","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327260207","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327260207","status":"completed","conclusion":"skipped","created_at":"2026-08-20T05:42:08Z","started_at":"2026-08-20T05:42:08Z","completed_at":"2026-08-20T05:42:08Z","name":"Create Changelog PR","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327260207","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":96327293022,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbY3YXg","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327293022","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327293022","status":"completed","conclusion":"success","created_at":"2026-08-20T05:42:19Z","started_at":"2026-08-20T05:42:21Z","completed_at":"2026-08-20T05:42:45Z","name":"Lint and Format Check","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:42:23Z","completed_at":"2026-08-20T05:42:24Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:42:24Z","completed_at":"2026-08-20T05:42:25Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:42:25Z","completed_at":"2026-08-20T05:42:25Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:42:25Z","completed_at":"2026-08-20T05:42:26Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:42:26Z","completed_at":"2026-08-20T05:42:27Z"},{"name":"Check formatting","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:42:27Z","completed_at":"2026-08-20T05:42:29Z"},{"name":"Run clippy","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:42:29Z","completed_at":"2026-08-20T05:42:41Z"},{"name":"Check file size limit","status":"completed","conclusion":"success","number":8,"started_at":"2026-08-20T05:42:41Z","completed_at":"2026-08-20T05:42:41Z"},{"name":"Run CI script tests","status":"completed","conclusion":"success","number":9,"started_at":"2026-08-20T05:42:41Z","completed_at":"2026-08-20T05:42:41Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":16,"started_at":"2026-08-20T05:42:41Z","completed_at":"2026-08-20T05:42:43Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":17,"started_at":"2026-08-20T05:42:43Z","completed_at":"2026-08-20T05:42:43Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":18,"started_at":"2026-08-20T05:42:43Z","completed_at":"2026-08-20T05:42:43Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":19,"started_at":"2026-08-20T05:42:43Z","completed_at":"2026-08-20T05:42:43Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327293022","labels":["ubuntu-latest"],"runner_id":1000044567,"runner_name":"GitHub Actions 1000044567","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96327293071,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbY3Yjw","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327293071","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327293071","status":"completed","conclusion":"success","created_at":"2026-08-20T05:42:19Z","started_at":"2026-08-20T05:42:21Z","completed_at":"2026-08-20T05:42:26Z","name":"Changelog Fragment Check","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:42:22Z","completed_at":"2026-08-20T05:42:22Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:42:22Z","completed_at":"2026-08-20T05:42:23Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:42:23Z","completed_at":"2026-08-20T05:42:24Z"},{"name":"Check for changelog fragments","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:42:24Z","completed_at":"2026-08-20T05:42:24Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:42:24Z","completed_at":"2026-08-20T05:42:24Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":8,"started_at":"2026-08-20T05:42:24Z","completed_at":"2026-08-20T05:42:25Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":9,"started_at":"2026-08-20T05:42:25Z","completed_at":"2026-08-20T05:42:25Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327293071","labels":["ubuntu-latest"],"runner_id":1000044568,"runner_name":"GitHub Actions 1000044568","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96327312956,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbY4mPA","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327312956","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327312956","status":"completed","conclusion":"success","created_at":"2026-08-20T05:42:26Z","started_at":"2026-08-20T05:42:28Z","completed_at":"2026-08-20T05:43:35Z","name":"Test (Rust on windows-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:42:30Z","completed_at":"2026-08-20T05:42:31Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:42:31Z","completed_at":"2026-08-20T05:42:39Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:42:39Z","completed_at":"2026-08-20T05:42:44Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:42:44Z","completed_at":"2026-08-20T05:42:45Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:42:45Z","completed_at":"2026-08-20T05:43:11Z"},{"name":"Run doc tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:43:11Z","completed_at":"2026-08-20T05:43:13Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:43:13Z","completed_at":"2026-08-20T05:43:13Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":13,"started_at":"2026-08-20T05:43:13Z","completed_at":"2026-08-20T05:43:32Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-08-20T05:43:32Z","completed_at":"2026-08-20T05:43:34Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-08-20T05:43:34Z","completed_at":"2026-08-20T05:43:34Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327312956","labels":["windows-latest"],"runner_id":1000044570,"runner_name":"GitHub Actions 1000044570","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96327312968,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbY4mSA","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327312968","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327312968","status":"completed","conclusion":"success","created_at":"2026-08-20T05:42:26Z","started_at":"2026-08-20T05:42:28Z","completed_at":"2026-08-20T05:42:48Z","name":"Test (Rust on ubuntu-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:42:29Z","completed_at":"2026-08-20T05:42:30Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:42:30Z","completed_at":"2026-08-20T05:42:31Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:42:31Z","completed_at":"2026-08-20T05:42:33Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:42:33Z","completed_at":"2026-08-20T05:42:33Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:42:33Z","completed_at":"2026-08-20T05:42:44Z"},{"name":"Run doc tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:42:44Z","completed_at":"2026-08-20T05:42:45Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:42:45Z","completed_at":"2026-08-20T05:42:45Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":13,"started_at":"2026-08-20T05:42:45Z","completed_at":"2026-08-20T05:42:46Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-08-20T05:42:46Z","completed_at":"2026-08-20T05:42:46Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-08-20T05:42:46Z","completed_at":"2026-08-20T05:42:46Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327312968","labels":["ubuntu-latest"],"runner_id":1000044569,"runner_name":"GitHub Actions 1000044569","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96327313070,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbY4mrg","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327313070","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327313070","status":"completed","conclusion":"success","created_at":"2026-08-20T05:42:26Z","started_at":"2026-08-20T05:42:29Z","completed_at":"2026-08-20T05:43:01Z","name":"Test (Rust on macos-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:42:30Z","completed_at":"2026-08-20T05:42:31Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:42:31Z","completed_at":"2026-08-20T05:42:34Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:42:34Z","completed_at":"2026-08-20T05:42:36Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:42:36Z","completed_at":"2026-08-20T05:42:37Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:42:37Z","completed_at":"2026-08-20T05:42:53Z"},{"name":"Run doc tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:42:53Z","completed_at":"2026-08-20T05:42:54Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:42:54Z","completed_at":"2026-08-20T05:42:55Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":13,"started_at":"2026-08-20T05:42:55Z","completed_at":"2026-08-20T05:42:57Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-08-20T05:42:57Z","completed_at":"2026-08-20T05:42:58Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-08-20T05:42:58Z","completed_at":"2026-08-20T05:42:59Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327313070","labels":["macos-latest"],"runner_id":1000044571,"runner_name":"GitHub Actions 1000044571","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96327525704,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZFlSA","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327525704","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327525704","status":"completed","conclusion":"success","created_at":"2026-08-20T05:43:36Z","started_at":"2026-08-20T05:43:38Z","completed_at":"2026-08-20T05:43:59Z","name":"Build Package","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:43:40Z","completed_at":"2026-08-20T05:43:41Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:43:41Z","completed_at":"2026-08-20T05:43:42Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:43:42Z","completed_at":"2026-08-20T05:43:43Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:43:43Z","completed_at":"2026-08-20T05:43:43Z"},{"name":"Build release","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:43:43Z","completed_at":"2026-08-20T05:43:54Z"},{"name":"Package crate","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:43:54Z","completed_at":"2026-08-20T05:43:54Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":11,"started_at":"2026-08-20T05:43:54Z","completed_at":"2026-08-20T05:43:56Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-08-20T05:43:56Z","completed_at":"2026-08-20T05:43:57Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-08-20T05:43:57Z","completed_at":"2026-08-20T05:43:57Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327525704","labels":["ubuntu-latest"],"runner_id":1000044572,"runner_name":"GitHub Actions 1000044572","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96327595224,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZJ02A","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327595224","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327595224","status":"completed","conclusion":"skipped","created_at":"2026-08-20T05:43:59Z","started_at":"2026-08-20T05:43:59Z","completed_at":"2026-08-20T05:43:59Z","name":"Auto Release","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327595224","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":96327595553,"run_id":32336595605,"workflow_name":"Rust CI/CD","head_branch":"issue-37-0e0bcabcea22","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336595605","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZJ2IQ","head_sha":"29373e36e8aa7cf7122795894ce4cb817061ba01","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96327595553","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336595605/job/96327595553","status":"completed","conclusion":"skipped","created_at":"2026-08-20T05:44:00Z","started_at":"2026-08-20T05:44:00Z","completed_at":"2026-08-20T05:43:59Z","name":"Instant Release","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96327595553","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null}]} \ No newline at end of file diff --git a/docs/case-studies/issue-39/data/ci-logs/jobs-32336933162.json b/docs/case-studies/issue-39/data/ci-logs/jobs-32336933162.json new file mode 100644 index 0000000..e6192cc --- /dev/null +++ b/docs/case-studies/issue-39/data/ci-logs/jobs-32336933162.json @@ -0,0 +1 @@ +{"total_count":11,"jobs":[{"id":96328196592,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZuh8A","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328196592","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328196592","status":"completed","conclusion":"success","created_at":"2026-08-20T05:47:14Z","started_at":"2026-08-20T05:47:16Z","completed_at":"2026-08-20T05:47:26Z","name":"Detect Changes","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:47:17Z","completed_at":"2026-08-20T05:47:18Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:47:18Z","completed_at":"2026-08-20T05:47:19Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:47:19Z","completed_at":"2026-08-20T05:47:23Z"},{"name":"Detect changes","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:47:23Z","completed_at":"2026-08-20T05:47:23Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:47:23Z","completed_at":"2026-08-20T05:47:23Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":8,"started_at":"2026-08-20T05:47:23Z","completed_at":"2026-08-20T05:47:24Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":9,"started_at":"2026-08-20T05:47:24Z","completed_at":"2026-08-20T05:47:24Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328196592","labels":["ubuntu-latest"],"runner_id":1000044580,"runner_name":"GitHub Actions 1000044580","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96328197402,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZulGg","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328197402","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328197402","status":"completed","conclusion":"skipped","created_at":"2026-08-20T05:47:14Z","started_at":"2026-08-20T05:47:14Z","completed_at":"2026-08-20T05:47:14Z","name":"Create Changelog PR","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328197402","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":96328197433,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZulOQ","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328197433","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328197433","status":"completed","conclusion":"skipped","created_at":"2026-08-20T05:47:14Z","started_at":"2026-08-20T05:47:14Z","completed_at":"2026-08-20T05:47:14Z","name":"Version Modification Check","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328197433","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":96328233886,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZwzng","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328233886","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328233886","status":"completed","conclusion":"success","created_at":"2026-08-20T05:47:26Z","started_at":"2026-08-20T05:47:28Z","completed_at":"2026-08-20T05:47:50Z","name":"Lint and Format Check","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:47:28Z","completed_at":"2026-08-20T05:47:30Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:47:30Z","completed_at":"2026-08-20T05:47:31Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:47:31Z","completed_at":"2026-08-20T05:47:33Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:47:33Z","completed_at":"2026-08-20T05:47:35Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:47:35Z","completed_at":"2026-08-20T05:47:35Z"},{"name":"Check formatting","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:47:35Z","completed_at":"2026-08-20T05:47:36Z"},{"name":"Run clippy","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:47:36Z","completed_at":"2026-08-20T05:47:46Z"},{"name":"Check file size limit","status":"completed","conclusion":"success","number":8,"started_at":"2026-08-20T05:47:46Z","completed_at":"2026-08-20T05:47:46Z"},{"name":"Run CI script tests","status":"completed","conclusion":"success","number":9,"started_at":"2026-08-20T05:47:46Z","completed_at":"2026-08-20T05:47:46Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":16,"started_at":"2026-08-20T05:47:46Z","completed_at":"2026-08-20T05:47:47Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":17,"started_at":"2026-08-20T05:47:47Z","completed_at":"2026-08-20T05:47:47Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":18,"started_at":"2026-08-20T05:47:47Z","completed_at":"2026-08-20T05:47:48Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":19,"started_at":"2026-08-20T05:47:48Z","completed_at":"2026-08-20T05:47:48Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328233886","labels":["ubuntu-latest"],"runner_id":1000044583,"runner_name":"GitHub Actions 1000044583","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96328234141,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZw0nQ","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328234141","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328234141","status":"completed","conclusion":"success","created_at":"2026-08-20T05:47:26Z","started_at":"2026-08-20T05:47:28Z","completed_at":"2026-08-20T05:47:52Z","name":"Test (Rust on ubuntu-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:47:29Z","completed_at":"2026-08-20T05:47:30Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:47:30Z","completed_at":"2026-08-20T05:47:31Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:47:31Z","completed_at":"2026-08-20T05:47:32Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:47:32Z","completed_at":"2026-08-20T05:47:32Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:47:32Z","completed_at":"2026-08-20T05:47:48Z"},{"name":"Run doc tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:47:48Z","completed_at":"2026-08-20T05:47:49Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:47:49Z","completed_at":"2026-08-20T05:47:49Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":13,"started_at":"2026-08-20T05:47:49Z","completed_at":"2026-08-20T05:47:50Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-08-20T05:47:50Z","completed_at":"2026-08-20T05:47:50Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-08-20T05:47:50Z","completed_at":"2026-08-20T05:47:50Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328234141","labels":["ubuntu-latest"],"runner_id":1000044581,"runner_name":"GitHub Actions 1000044581","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96328234183,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZw0xw","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328234183","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328234183","status":"completed","conclusion":"success","created_at":"2026-08-20T05:47:26Z","started_at":"2026-08-20T05:47:29Z","completed_at":"2026-08-20T05:47:57Z","name":"Test (Rust on macos-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:47:30Z","completed_at":"2026-08-20T05:47:32Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:47:32Z","completed_at":"2026-08-20T05:47:34Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:47:34Z","completed_at":"2026-08-20T05:47:36Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:47:36Z","completed_at":"2026-08-20T05:47:37Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:47:37Z","completed_at":"2026-08-20T05:47:50Z"},{"name":"Run doc tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:47:50Z","completed_at":"2026-08-20T05:47:50Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:47:50Z","completed_at":"2026-08-20T05:47:50Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":13,"started_at":"2026-08-20T05:47:51Z","completed_at":"2026-08-20T05:47:53Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-08-20T05:47:53Z","completed_at":"2026-08-20T05:47:54Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-08-20T05:47:54Z","completed_at":"2026-08-20T05:47:55Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328234183","labels":["macos-latest"],"runner_id":1000044582,"runner_name":"GitHub Actions 1000044582","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96328234416,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZw1sA","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328234416","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328234416","status":"completed","conclusion":"success","created_at":"2026-08-20T05:47:26Z","started_at":"2026-08-20T05:47:28Z","completed_at":"2026-08-20T05:48:33Z","name":"Test (Rust on windows-latest)","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:47:29Z","completed_at":"2026-08-20T05:47:30Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:47:30Z","completed_at":"2026-08-20T05:47:36Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:47:36Z","completed_at":"2026-08-20T05:47:41Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:47:41Z","completed_at":"2026-08-20T05:47:42Z"},{"name":"Run tests","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:47:42Z","completed_at":"2026-08-20T05:48:09Z"},{"name":"Run doc tests","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:48:09Z","completed_at":"2026-08-20T05:48:11Z"},{"name":"Run example","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:48:11Z","completed_at":"2026-08-20T05:48:11Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":13,"started_at":"2026-08-20T05:48:11Z","completed_at":"2026-08-20T05:48:29Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":14,"started_at":"2026-08-20T05:48:29Z","completed_at":"2026-08-20T05:48:31Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":15,"started_at":"2026-08-20T05:48:31Z","completed_at":"2026-08-20T05:48:31Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328234416","labels":["windows-latest"],"runner_id":1000044584,"runner_name":"GitHub Actions 1000044584","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96328234906,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZw3mg","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328234906","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328234906","status":"completed","conclusion":"skipped","created_at":"2026-08-20T05:47:26Z","started_at":"2026-08-20T05:47:26Z","completed_at":"2026-08-20T05:47:26Z","name":"Changelog Fragment Check","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328234906","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null},{"id":96328437812,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbZ9QNA","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328437812","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328437812","status":"completed","conclusion":"success","created_at":"2026-08-20T05:48:33Z","started_at":"2026-08-20T05:48:37Z","completed_at":"2026-08-20T05:49:00Z","name":"Build Package","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:48:38Z","completed_at":"2026-08-20T05:48:39Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:48:39Z","completed_at":"2026-08-20T05:48:40Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:48:40Z","completed_at":"2026-08-20T05:48:42Z"},{"name":"Cache cargo dependencies","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:48:42Z","completed_at":"2026-08-20T05:48:43Z"},{"name":"Build release","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:48:43Z","completed_at":"2026-08-20T05:48:55Z"},{"name":"Package crate","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:48:55Z","completed_at":"2026-08-20T05:48:56Z"},{"name":"Post Cache cargo dependencies","status":"completed","conclusion":"success","number":11,"started_at":"2026-08-20T05:48:56Z","completed_at":"2026-08-20T05:48:58Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":12,"started_at":"2026-08-20T05:48:58Z","completed_at":"2026-08-20T05:48:58Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":13,"started_at":"2026-08-20T05:48:58Z","completed_at":"2026-08-20T05:48:58Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328437812","labels":["ubuntu-latest"],"runner_id":1000044585,"runner_name":"GitHub Actions 1000044585","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96328523808,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbaCgIA","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328523808","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328523808","status":"completed","conclusion":"success","created_at":"2026-08-20T05:49:01Z","started_at":"2026-08-20T05:49:03Z","completed_at":"2026-08-20T05:49:37Z","name":"Auto Release","steps":[{"name":"Set up job","status":"completed","conclusion":"success","number":1,"started_at":"2026-08-20T05:49:03Z","completed_at":"2026-08-20T05:49:04Z"},{"name":"Run actions/checkout@v4","status":"completed","conclusion":"success","number":2,"started_at":"2026-08-20T05:49:04Z","completed_at":"2026-08-20T05:49:05Z"},{"name":"Set up Rust","status":"completed","conclusion":"success","number":3,"started_at":"2026-08-20T05:49:05Z","completed_at":"2026-08-20T05:49:06Z"},{"name":"Setup Node.js","status":"completed","conclusion":"success","number":4,"started_at":"2026-08-20T05:49:06Z","completed_at":"2026-08-20T05:49:09Z"},{"name":"Configure git","status":"completed","conclusion":"success","number":5,"started_at":"2026-08-20T05:49:09Z","completed_at":"2026-08-20T05:49:09Z"},{"name":"Determine bump type from changelog fragments","status":"completed","conclusion":"success","number":6,"started_at":"2026-08-20T05:49:09Z","completed_at":"2026-08-20T05:49:09Z"},{"name":"Check if release is needed","status":"completed","conclusion":"success","number":7,"started_at":"2026-08-20T05:49:09Z","completed_at":"2026-08-20T05:49:09Z"},{"name":"Collect changelog and bump version","status":"completed","conclusion":"success","number":8,"started_at":"2026-08-20T05:49:09Z","completed_at":"2026-08-20T05:49:17Z"},{"name":"Get current version","status":"completed","conclusion":"success","number":9,"started_at":"2026-08-20T05:49:17Z","completed_at":"2026-08-20T05:49:17Z"},{"name":"Build release","status":"completed","conclusion":"success","number":10,"started_at":"2026-08-20T05:49:17Z","completed_at":"2026-08-20T05:49:27Z"},{"name":"Publish to crates.io","status":"completed","conclusion":"success","number":11,"started_at":"2026-08-20T05:49:27Z","completed_at":"2026-08-20T05:49:33Z"},{"name":"Create GitHub Release","status":"completed","conclusion":"success","number":12,"started_at":"2026-08-20T05:49:33Z","completed_at":"2026-08-20T05:49:35Z"},{"name":"Post Setup Node.js","status":"completed","conclusion":"success","number":23,"started_at":"2026-08-20T05:49:35Z","completed_at":"2026-08-20T05:49:35Z"},{"name":"Post Run actions/checkout@v4","status":"completed","conclusion":"success","number":24,"started_at":"2026-08-20T05:49:35Z","completed_at":"2026-08-20T05:49:36Z"},{"name":"Complete job","status":"completed","conclusion":"success","number":25,"started_at":"2026-08-20T05:49:36Z","completed_at":"2026-08-20T05:49:36Z"}],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328523808","labels":["ubuntu-latest"],"runner_id":1000044586,"runner_name":"GitHub Actions 1000044586","runner_group_id":0,"runner_group_name":"GitHub Actions"},{"id":96328524490,"run_id":32336933162,"workflow_name":"Rust CI/CD","head_branch":"main","run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/runs/32336933162","run_attempt":1,"node_id":"CR_kwDOQWrSmc8AAAAWbaCiyg","head_sha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","url":"https://api.github.com/repos/link-foundation/lino-objects-codec/actions/jobs/96328524490","html_url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32336933162/job/96328524490","status":"completed","conclusion":"skipped","created_at":"2026-08-20T05:49:01Z","started_at":"2026-08-20T05:49:01Z","completed_at":"2026-08-20T05:49:01Z","name":"Instant Release","steps":[],"check_run_url":"https://api.github.com/repos/link-foundation/lino-objects-codec/check-runs/96328524490","labels":["ubuntu-latest"],"runner_id":null,"runner_name":null,"runner_group_id":null,"runner_group_name":null}]} \ No newline at end of file diff --git a/docs/case-studies/issue-39/data/ci-runs-recent.json b/docs/case-studies/issue-39/data/ci-runs-recent.json index fdbb7a3..e99d0e3 100644 --- a/docs/case-studies/issue-39/data/ci-runs-recent.json +++ b/docs/case-studies/issue-39/data/ci-runs-recent.json @@ -1 +1 @@ -[{"conclusion":"success","createdAt":"2026-08-20T05:47:14Z","databaseId":32336933162,"event":"push","headBranch":"main","headSha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-08-20T05:42:07Z","databaseId":32336595605,"event":"pull_request","headBranch":"issue-37-0e0bcabcea22","headSha":"29373e36e8aa7cf7122795894ce4cb817061ba01","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-10T19:42:06Z","databaseId":25637974944,"event":"push","headBranch":"main","headSha":"0529f91279da52f6e6b475cb2169b04edbd431ea","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-10T19:38:16Z","databaseId":25637892451,"event":"pull_request","headBranch":"issue-35-03946ff48852","headSha":"a9ad369d371735f6f9194f905e8920044c9c62e4","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356119,"event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356114,"event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"failure","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356076,"event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"failure","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356073,"event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847432,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847426,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847417,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847415,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507413,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507401,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507389,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507388,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405283,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405261,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405242,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"failure","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405237,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053655,"event":"push","headBranch":"main","headSha":"e1303e202fc96b20cae6adfef52ff132d36f7366","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053645,"event":"push","headBranch":"main","headSha":"e1303e202fc96b20cae6adfef52ff132d36f7366","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053638,"event":"push","headBranch":"main","headSha":"e1303e202fc96b20cae6adfef52ff132d36f7366","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384224,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"dc46eb61a843463864d8b7e4ab1178609b7afa12","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384221,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"dc46eb61a843463864d8b7e4ab1178609b7afa12","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384218,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"dc46eb61a843463864d8b7e4ab1178609b7afa12","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279804,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"c78eb96863b3a80c2925ea756f638c8a3c75f58a","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279793,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"c78eb96863b3a80c2925ea756f638c8a3c75f58a","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279785,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"c78eb96863b3a80c2925ea756f638c8a3c75f58a","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485840,"event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485829,"event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485826,"event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485825,"event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881566,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"JavaScript CI/CD","status":"completed","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881563,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881551,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881549,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710867,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"Python CI/CD","status":"completed","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710859,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"Rust CI/CD","status":"completed","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710856,"event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"C# CI/CD","status":"completed","workflowName":"C# CI/CD"}] +[{"conclusion":"success","createdAt":"2026-08-20T05:47:14Z","databaseId":32336933162,"event":"push","headBranch":"main","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-08-20T05:42:07Z","databaseId":32336595605,"event":"pull_request","headBranch":"issue-37-0e0bcabcea22","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-10T19:42:06Z","databaseId":25637974944,"event":"push","headBranch":"main","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-10T19:38:16Z","databaseId":25637892451,"event":"pull_request","headBranch":"issue-35-03946ff48852","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356119,"event":"push","headBranch":"main","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356114,"event":"push","headBranch":"main","workflowName":"JavaScript CI/CD"},{"conclusion":"failure","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356076,"event":"push","headBranch":"main","workflowName":"Python CI/CD"},{"conclusion":"failure","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356073,"event":"push","headBranch":"main","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847432,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847426,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847417,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847415,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507413,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507401,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507389,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507388,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"C# CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405283,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405261,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405242,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"C# CI/CD"},{"conclusion":"failure","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405237,"event":"pull_request","headBranch":"issue-33-2c527aa50b81","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053655,"event":"push","headBranch":"main","workflowName":"Rust CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053645,"event":"push","headBranch":"main","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053638,"event":"push","headBranch":"main","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384224,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384221,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","workflowName":"JavaScript CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384218,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279804,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","workflowName":"Python CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279793,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","workflowName":"Rust CI/CD"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279785,"event":"pull_request","headBranch":"issue-31-bd2874f2d076","workflowName":"JavaScript CI/CD"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485840,"event":"push","headBranch":"main","workflowName":"JavaScript CI/CD"}] diff --git a/js/src/index.js b/js/src/index.js index 7910fa7..ccbf01c 100644 --- a/js/src/index.js +++ b/js/src/index.js @@ -15,8 +15,27 @@ * @module lino-objects-codec */ -// Typed object codec (preserves types with markers like (int 42), (str base64)) -export { ObjectCodec, encode, decode } from './codec.js'; +// Object codec: `encode` writes the readable, indented format; `decode` reads +// both that and the compact (type-tagged, base64) format. +export { + ObjectCodec, + encode, + encodeCompact, + encodeObfuscated, + decode, + decodeCompact, + isCompactNotation, +} from './codec.js'; + +// Readable format internals: constants and the error raised on circular values +export { + DEFAULT_INDENT, + BASE64_MARKER, + CircularReferenceError, +} from './readable.js'; + +// Opt-in tracing, shared switch across all four language implementations +export { DEBUG_ENV_VAR, isDebugEnabled, setDebugEnabled } from './debug.js'; // Formatting utilities for readable indented data and compact JSON/Lino conversion export { diff --git a/js/src/readable.js b/js/src/readable.js index 33696e6..9be5b6b 100644 --- a/js/src/readable.js +++ b/js/src/readable.js @@ -67,24 +67,35 @@ const BARE_LITERALS = new Map([ /** Characters that cannot appear in a bare (unquoted) reference. */ const QUOTE_CHARS = ['"', "'", '`']; -/** - * Unicode control characters (categories Cc): the only characters that cannot be - * written as plain text, because they break the line structure of the document. - */ -const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/; - /** Characters that force an object key to be quoted. */ const KEY_NEEDS_QUOTES = /[\s()':`"]/; +/** + * Raised when a value cannot be written because it refers back to itself. + * + * The readable form writes a plain tree and has no place to put the `obj_N` + * definition ids that name a shared node, so a cycle cannot be represented. + * `encodeCompact` handles cycles. + */ +export class CircularReferenceError extends TypeError { + /** @param {string} message - Why the value could not be written */ + constructor(message) { + super(message); + this.name = 'CircularReferenceError'; + } +} + /** * Encode a value into the readable, indented Links Notation form. * @param {*} value - The value to encode * @param {string} [indent] - Indentation string used per nesting level * @returns {string} The readable Links Notation document + * @throws {CircularReferenceError} If the value refers back to itself + * @throws {TypeError} If the value holds a type this format cannot write */ export function encode(value, indent = DEFAULT_INDENT) { const out = []; - writeValue(value, indent, 0, out); + writeValue(value, indent, 0, out, new Set()); return out.join(''); } @@ -113,34 +124,57 @@ export function decode(text) { // === Encoding === -function writeValue(value, indent, level, out) { +function writeValue(value, indent, level, out, path) { if (Array.isArray(value)) { + enterPath(value, path); writeRows(value, indent, level, out, (item) => - writeValue(item, indent, level + 1, out) + writeValue(item, indent, level + 1, out, path) ); + path.delete(value); return; } if (isPlainContainer(value)) { + enterPath(value, path); const entries = Object.entries(value); if (entries.length === 0) { // An empty object spans two lines; `()` on one line is an empty array. out.push('(\n'); pushIndent(indent, level, out); out.push(')'); + path.delete(value); return; } writeRows(entries, indent, level, out, ([key, child]) => { out.push(formatKey(key)); out.push(' '); - writeValue(child, indent, level + 1, out); + writeValue(child, indent, level + 1, out, path); }); + path.delete(value); return; } out.push(formatScalar(value)); } +/** + * Mark a container as being written, so a reference back to it is caught. + * + * Only the containers on the way down are tracked: the same object appearing + * twice side by side is written twice, which reads back as two equal values. + * @param {object} value - The container being entered + * @param {Set} path - Containers currently being written + */ +function enterPath(value, path) { + if (path.has(value)) { + throw new CircularReferenceError( + 'Cannot write a circular reference in the readable format; ' + + 'use encodeCompact, which names shared nodes with obj_N ids' + ); + } + path.add(value); +} + /** * Write a container as `(`, one indented line per item, then `)`. * An empty container collapses to `()`, which reads back as an empty array. @@ -246,7 +280,14 @@ function formatString(value) { * @returns {boolean} True when the string has to be encoded */ function needsEncoding(value) { - return CONTROL_CHARACTERS.test(value); + for (const char of value) { + const code = char.codePointAt(0); + // Unicode category Cc: the C0 and C1 control ranges. + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) { + return true; + } + } + return false; } function quote(value) { diff --git a/js/tests/test_circular_references.test.js b/js/tests/test_circular_references.test.js index 36fe3dd..ec21b75 100644 --- a/js/tests/test_circular_references.test.js +++ b/js/tests/test_circular_references.test.js @@ -1,10 +1,21 @@ /** * Tests for encoding/decoding circular references and shared object references. + * + * Object identity -- a cycle, or two fields pointing at the same object -- is a + * property of the compact format, which names shared nodes with `obj_N` ids. The + * readable format writes a plain tree and has nowhere to put those ids, so it + * rejects a circular value instead of looping; that is covered at the end of + * this file. `decode` reads both formats, so it is used unaliased throughout. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { encode, decode } from '../src/index.js'; +import { + encodeCompact as encode, + decode, + encode as encodeReadable, + CircularReferenceError, +} from '../src/index.js'; // Tests for circular references in arrays test('self-referencing array', () => { @@ -279,3 +290,31 @@ test('decoder rejects legacy (ref X) marker as unknown type', () => { /Unknown type marker:\s*ref/ ); }); + +// The readable format cannot name a shared node, so it refuses a cycle rather +// than looping forever or silently writing a truncated document. +test('readable format rejects a self-referencing object', () => { + const obj = { name: 'root' }; + obj.self = obj; + + assert.throws(() => encodeReadable({ obj }), CircularReferenceError); +}); + +test('readable format rejects a self-referencing array', () => { + const arr = [1, 2]; + arr.push(arr); + + assert.throws(() => encodeReadable({ obj: arr }), CircularReferenceError); +}); + +test('readable format writes a shared object twice', () => { + const shared = { x: 1 }; + + const text = encodeReadable({ obj: { a: shared, b: shared } }); + const decoded = decode({ notation: text }); + + assert.deepEqual(decoded, { a: { x: 1 }, b: { x: 1 } }); + // The values are equal but no longer the same object: only the compact format + // keeps identity. + assert.notEqual(decoded.a, decoded.b); +}); diff --git a/python/.ruff.toml b/python/.ruff.toml deleted file mode 100644 index e6a459e..0000000 --- a/python/.ruff.toml +++ /dev/null @@ -1,5 +0,0 @@ -# Ruff configuration -# This file provides additional settings beyond pyproject.toml - -[lint.isort] -known-first-party = ["my_package"] diff --git a/python/pyproject.toml b/python/pyproject.toml index 1741015..4cf4575 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -47,14 +47,22 @@ addopts = "-v --cov=link_notation_objects_codec --cov-report=term-missing" [tool.ruff] line-length = 100 -target-version = "py38" +# Matches `requires-python` above: an older target silently keeps deprecated +# typing constructs passing review. +target-version = "py313" [tool.ruff.lint] select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM"] ignore = [] +# Kept in pyproject.toml on purpose: ruff reads exactly one configuration file, +# so a `.ruff.toml` next to this file would replace everything above rather than +# add to it, and the rule selection would silently become ruff's default one. +[tool.ruff.lint.isort] +known-first-party = ["link_notation_objects_codec"] + [tool.mypy] -python_version = "3.9" +python_version = "3.13" warn_return_any = false warn_unused_configs = true disallow_untyped_defs = false diff --git a/python/src/link_notation_objects_codec/__init__.py b/python/src/link_notation_objects_codec/__init__.py index 620e085..049b305 100644 --- a/python/src/link_notation_objects_codec/__init__.py +++ b/python/src/link_notation_objects_codec/__init__.py @@ -24,7 +24,12 @@ parse_indented, unescape_reference, ) -from .readable import BASE64_MARKER, DEFAULT_INDENT, ReadableFormatError +from .readable import ( + BASE64_MARKER, + DEFAULT_INDENT, + CircularReferenceError, + ReadableFormatError, +) __version__ = "0.3.0" __all__ = [ @@ -42,6 +47,7 @@ "DEFAULT_INDENT", "BASE64_MARKER", "ReadableFormatError", + "CircularReferenceError", "DEBUG_ENV_VAR", "is_debug_enabled", "set_debug_enabled", diff --git a/python/src/link_notation_objects_codec/codec.py b/python/src/link_notation_objects_codec/codec.py index 725c93c..3133893 100644 --- a/python/src/link_notation_objects_codec/codec.py +++ b/python/src/link_notation_objects_codec/codec.py @@ -15,7 +15,7 @@ import base64 import math import re -from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple +from typing import Any from links_notation import Link, Parser @@ -28,7 +28,7 @@ #: ``None``/``list``/``dict`` where JavaScript and Rust write #: ``null``/``array``/``object`` -- so every implementation accepts the union and #: can read a compact document written by any of the others. -_COMPACT_TYPE_MARKERS: FrozenSet[str] = frozenset( +_COMPACT_TYPE_MARKERS: frozenset[str] = frozenset( { "null", "None", @@ -95,16 +95,16 @@ def __init__(self) -> None: """Initialize the codec.""" self.parser = Parser() # For tracking object identity during encoding - self._encode_memo: Dict[int, str] = {} + self._encode_memo: dict[int, str] = {} self._encode_counter: int = 0 # For tracking which objects need IDs (referenced multiple times or circularly) - self._needs_id: Set[int] = set() + self._needs_id: set[int] = set() # For storing all definitions during encoding - self._all_definitions: List[Tuple[str, Link]] = [] + self._all_definitions: list[tuple[str, Link]] = [] # For tracking references during decoding - self._decode_memo: Dict[str, Any] = {} + self._decode_memo: dict[str, Any] = {} # For storing all links during multi-link decoding - self._all_links: List[Any] = [] + self._all_links: list[Any] = [] def _make_link(self, *parts: str) -> Link: """ @@ -123,8 +123,8 @@ def _make_link(self, *parts: str) -> Link: def _find_objects_needing_ids( self, obj: Any, - seen: Optional[Dict[int, List[int]]] = None, - path: Optional[List[int]] = None, + seen: dict[int, list[int]] | None = None, + path: list[int] | None = None, ) -> None: """ First pass: identify which objects need IDs (referenced multiple times or circularly). @@ -309,9 +309,7 @@ def decode_compact(self, notation: str) -> Any: return self._decode_link(link) - def _encode_value( - self, obj: Any, visited: Optional[Set[int]] = None, depth: int = 0 - ) -> Link: + def _encode_value(self, obj: Any, visited: set[int] | None = None, depth: int = 0) -> Link: """ Encode a value into a Link. @@ -390,9 +388,7 @@ def _encode_value( if obj_id in self._encode_memo: ref_id = self._encode_memo[obj_id] # Create the definition with self-reference ID - definition = Link( - link_id=ref_id, values=[Link(link_id=self.TYPE_LIST)] + parts - ) + definition = Link(link_id=ref_id, values=[Link(link_id=self.TYPE_LIST)] + parts) # Store for multi-link output if not at top level if depth > 0: self._all_definitions.append((ref_id, definition)) @@ -417,9 +413,7 @@ def _encode_value( if obj_id in self._encode_memo: ref_id = self._encode_memo[obj_id] # Create the definition with self-reference ID - definition = Link( - link_id=ref_id, values=[Link(link_id=self.TYPE_DICT)] + parts - ) + definition = Link(link_id=ref_id, values=[Link(link_id=self.TYPE_DICT)] + parts) # Store for multi-link output if not at top level if depth > 0: self._all_definitions.append((ref_id, definition)) @@ -464,7 +458,7 @@ def _decode_link(self, link: Link) -> Any: return self._decode_link(other_link) # Not found in links - create empty list as fallback - result: List[Any] = [] + result: list[Any] = [] self._decode_memo[link.id] = result return result @@ -539,7 +533,7 @@ def _decode_link(self, link: Link) -> Any: start_idx = 1 list_id = self_ref_id # Use self-reference ID from link.id if present - result_list: List[Any] = [] + result_list: list[Any] = [] if list_id: self._decode_memo[list_id] = result_list @@ -553,7 +547,7 @@ def _decode_link(self, link: Link) -> Any: start_idx = 1 dict_id = self_ref_id # Use self-reference ID from link.id if present - result_dict: Dict[Any, Any] = {} + result_dict: dict[Any, Any] = {} if dict_id: self._decode_memo[dict_id] = result_dict diff --git a/python/src/link_notation_objects_codec/debug.py b/python/src/link_notation_objects_codec/debug.py index 182171d..ae84167 100644 --- a/python/src/link_notation_objects_codec/debug.py +++ b/python/src/link_notation_objects_codec/debug.py @@ -11,14 +11,14 @@ import os import sys -from typing import Callable, Optional +from collections.abc import Callable #: Name of the environment variable that turns tracing on. DEBUG_ENV_VAR = "LINO_CODEC_DEBUG" _TRUTHY = frozenset({"1", "true", "yes", "on"}) -_overridden: Optional[bool] = None +_overridden: bool | None = None def is_debug_enabled() -> bool: @@ -29,7 +29,7 @@ def is_debug_enabled() -> bool: return raw is not None and raw.strip().lower() in _TRUTHY -def set_debug_enabled(enabled: Optional[bool]) -> None: +def set_debug_enabled(enabled: bool | None) -> None: """Turn tracing on or off from code, overriding the environment variable. Args: diff --git a/python/src/link_notation_objects_codec/format.py b/python/src/link_notation_objects_codec/format.py index e39268a..5d9a3b7 100644 --- a/python/src/link_notation_objects_codec/format.py +++ b/python/src/link_notation_objects_codec/format.py @@ -6,7 +6,7 @@ """ import re -from typing import Any, Dict, Optional, Tuple +from typing import Any from links_notation import Parser @@ -62,7 +62,7 @@ def escape_reference(value: Any) -> str: return f"'{s}'" -def unescape_reference(s: Optional[str]) -> Optional[str]: +def unescape_reference(s: str | None) -> str | None: """ Unescape a reference from Links Notation format. @@ -120,8 +120,8 @@ def _format_indented_value(value: Any) -> str: def format_indented( - id: str, - obj: Dict[str, Any], + id: str, # noqa: A002 - part of the public API since 0.1.0; renaming would break callers + obj: dict[str, Any], indent: str = " ", ) -> str: """ @@ -139,7 +139,8 @@ def format_indented( ... '6dcf4c1b-ff3f-482c-95ab-711ea7d1b019', ... {'uuid': '6dcf4c1b-ff3f-482c-95ab-711ea7d1b019', 'status': 'executed'} ... ) - '6dcf4c1b-ff3f-482c-95ab-711ea7d1b019\\n uuid "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019"\\n status "executed"' + '6dcf4c1b-ff3f-482c-95ab-711ea7d1b019\\n uuid \\ +"6dcf4c1b-ff3f-482c-95ab-711ea7d1b019"\\n status "executed"' Args: id: The object identifier (displayed on first line) @@ -168,7 +169,7 @@ def format_indented( return "\n".join(lines) -def parse_indented(text: str) -> Tuple[str, Dict[str, Any]]: +def parse_indented(text: str) -> tuple[str, dict[str, Any]]: """ Parse an indented Links Notation string back to an object. @@ -227,7 +228,7 @@ def parse_indented(text: str) -> Tuple[str, Dict[str, Any]]: # Extract id and key-value pairs from parsed result main_link = parsed[0] result_id = main_link.id or "" - obj: Dict[str, Any] = {} + obj: dict[str, Any] = {} # Process the values array - each entry is a doublet (key value) for child in main_link.values or []: diff --git a/python/src/link_notation_objects_codec/readable.py b/python/src/link_notation_objects_codec/readable.py index 8f9fe4b..19bfa35 100644 --- a/python/src/link_notation_objects_codec/readable.py +++ b/python/src/link_notation_objects_codec/readable.py @@ -48,7 +48,9 @@ import math import re import unicodedata -from typing import Any, Dict, List, Optional, Sequence, Tuple, Union +from collections.abc import Iterator, Sequence +from contextlib import contextmanager +from typing import Any from .debug import trace @@ -72,6 +74,15 @@ class ReadableFormatError(ValueError): """Raised when a readable document cannot be parsed.""" +class CircularReferenceError(ValueError): + """Raised when a value cannot be written because it refers back to itself. + + The readable form writes a plain tree and has no place to put the ``obj_N`` + definition ids that name a shared node, so a cycle cannot be represented. + :func:`link_notation_objects_codec.encode_compact` handles cycles. + """ + + def encode(value: Any, indent: str = DEFAULT_INDENT) -> str: """Encode a value into the readable, indented Links Notation form. @@ -81,9 +92,13 @@ def encode(value: Any, indent: str = DEFAULT_INDENT) -> str: Returns: The readable Links Notation document. + + Raises: + CircularReferenceError: If the value refers back to itself. + TypeError: If the value holds a type this format cannot write. """ - out: List[str] = [] - _write_value(value, indent, 0, out) + out: list[str] = [] + _write_value(value, indent, 0, out, set()) return "".join(out) @@ -117,38 +132,62 @@ def decode(text: str) -> Any: # === Encoding === -def _write_value(value: Any, indent: str, level: int, out: List[str]) -> None: +def _write_value(value: Any, indent: str, level: int, out: list[str], path: set[int]) -> None: if isinstance(value, dict): - items = list(value.items()) - if not items: - # An empty dict spans two lines; ``()`` on one line is an empty list. - out.append("(\n") - _push_indent(indent, level, out) - out.append(")") - return - - def write_pair(pair: Tuple[Any, Any]) -> None: - key, child = pair - out.append(_format_key(key)) - out.append(" ") - _write_value(child, indent, level + 1, out) - - _write_rows(items, indent, level, out, write_pair) + with _on_path(value, path): + items = list(value.items()) + if not items: + # An empty dict spans two lines; ``()`` on one line is an empty list. + out.append("(\n") + _push_indent(indent, level, out) + out.append(")") + return + + def write_pair(pair: tuple[Any, Any]) -> None: + key, child = pair + out.append(_format_key(key)) + out.append(" ") + _write_value(child, indent, level + 1, out, path) + + _write_rows(items, indent, level, out, write_pair) return if isinstance(value, (list, tuple, set, frozenset)): - items_seq: Sequence[Any] = list(value) + with _on_path(value, path): + items_seq: Sequence[Any] = list(value) - def write_item(item: Any) -> None: - _write_value(item, indent, level + 1, out) + def write_item(item: Any) -> None: + _write_value(item, indent, level + 1, out, path) - _write_rows(items_seq, indent, level, out, write_item) + _write_rows(items_seq, indent, level, out, write_item) return out.append(_format_scalar(value)) -def _write_rows(items: Sequence[Any], indent: str, level: int, out: List[str], write_item: Any) -> None: +@contextmanager +def _on_path(value: Any, path: set[int]) -> Iterator[None]: + """Mark a container as being written, so a reference back to it is caught. + + Only the containers on the way down are tracked: the same object appearing + twice side by side is written twice, which reads back as two equal values. + """ + marker = id(value) + if marker in path: + raise CircularReferenceError( + "Cannot write a circular reference in the readable format; " + "use encode_compact, which names shared nodes with obj_N ids" + ) + path.add(marker) + try: + yield + finally: + path.discard(marker) + + +def _write_rows( + items: Sequence[Any], indent: str, level: int, out: list[str], write_item: Any +) -> None: """Write a container as ``(``, one indented line per item, then ``)``. An empty container collapses to ``()``, which reads back as an empty list. @@ -167,7 +206,7 @@ def _write_rows(items: Sequence[Any], indent: str, level: int, out: List[str], w out.append(")") -def _push_indent(indent: str, level: int, out: List[str]) -> None: +def _push_indent(indent: str, level: int, out: list[str]) -> None: for _ in range(level): out.append(indent) @@ -281,7 +320,7 @@ def __init__( is_ref: bool, value: str = "", quoted: bool = False, - rows: Optional[List[List["_Node"]]] = None, + rows: list[list["_Node"]] | None = None, multiline: bool = False, ) -> None: self.is_ref = is_ref @@ -291,9 +330,9 @@ def __init__( self.multiline = multiline -def _tokenize(text: str) -> List[_Token]: +def _tokenize(text: str) -> list[_Token]: """Split a document into parentheses, newlines and references.""" - tokens: List[_Token] = [] + tokens: list[_Token] = [] i = 0 length = len(text) @@ -316,16 +355,21 @@ def _tokenize(text: str) -> List[_Token]: tokens.append(_Token(_TOKEN_REF, value, quoted=True)) else: start = i - while i < length and not text[i].isspace() and text[i] not in "()" and text[i] not in _QUOTE_CHARS: + while ( + i < length + and not text[i].isspace() + and text[i] not in "()" + and text[i] not in _QUOTE_CHARS + ): i += 1 tokens.append(_Token(_TOKEN_REF, text[start:i], quoted=False)) return tokens -def _read_quoted(text: str, start: int, quote_char: str) -> Tuple[str, int]: +def _read_quoted(text: str, start: int, quote_char: str) -> tuple[str, int]: """Read a quoted reference, where a doubled quote character means a literal one.""" - parts: List[str] = [] + parts: list[str] = [] i = start + 1 length = len(text) @@ -345,17 +389,17 @@ def _read_quoted(text: str, start: int, quote_char: str) -> Tuple[str, int]: class _Cursor: """Cursor over the token stream, turning tokens into nodes and rows.""" - def __init__(self, tokens: List[_Token]) -> None: + def __init__(self, tokens: list[_Token]) -> None: self.tokens = tokens self.pos = 0 - def parse_rows(self, top_level: bool) -> List[List[_Node]]: + def parse_rows(self, top_level: bool) -> list[list[_Node]]: """Parse rows until the matching ``)`` (or the end of input at the top level). A row is one line: the values written between two newlines. """ - rows: List[List[_Node]] = [] - row: List[_Node] = [] + rows: list[list[_Node]] = [] + row: list[_Node] = [] while self.pos < len(self.tokens): token = self.tokens[self.pos] @@ -417,7 +461,7 @@ def _node_to_value(node: _Node) -> Any: return _rows_to_value(node.rows, node.multiline) -def _rows_to_value(rows: List[List[_Node]], multiline: bool) -> Any: +def _rows_to_value(rows: list[list[_Node]], multiline: bool) -> Any: if not rows: return {} if multiline else [] @@ -429,19 +473,19 @@ def _rows_to_value(rows: List[List[_Node]], multiline: bool) -> Any: is_dict = all(len(row) == 2 and row[0].is_ref for row in rows) if is_dict: - result: Dict[str, Any] = {} + result: dict[str, Any] = {} for row in rows: result[row[0].value] = _node_to_value(row[1]) return result - items: List[Any] = [] + items: list[Any] = [] for row in rows: for node in row: items.append(_node_to_value(node)) return items -def _decode_marked_value(rows: List[List[_Node]]) -> Optional[Tuple[str]]: +def _decode_marked_value(rows: list[list[_Node]]) -> tuple[str] | None: """Recognise ``(base64 "...")``, the individual marker for values that could not be written as text. @@ -465,7 +509,7 @@ def _decode_marked_value(rows: List[List[_Node]]) -> Optional[Tuple[str]]: return (decoded,) -def _ref_to_value(value: str, quoted: bool) -> Union[None, bool, int, float, str]: +def _ref_to_value(value: str, quoted: bool) -> None | bool | int | float | str: """Convert a reference to a value. Quoted references are always strings; bare references keep the type they were diff --git a/python/tests/test_circular_references.py b/python/tests/test_circular_references.py index 428d6e3..6645a42 100644 --- a/python/tests/test_circular_references.py +++ b/python/tests/test_circular_references.py @@ -1,8 +1,25 @@ -"""Tests for encoding/decoding objects with circular references.""" +"""Tests for encoding/decoding objects with circular references. + +Object identity -- a cycle, or two keys pointing at the same object -- is a +property of the compact format, which names shared nodes with ``obj_N`` ids. The +readable format writes a plain tree and has nowhere to put those ids, so it +rejects a circular value instead of looping; that is covered by +``TestReadableFormatAndCircularReferences``. :func:`decode` reads both formats, +so it is used unaliased throughout. +""" import pytest -from link_notation_objects_codec import decode, encode +from link_notation_objects_codec import ( + CircularReferenceError, + decode, +) +from link_notation_objects_codec import ( + encode as encode_readable, +) +from link_notation_objects_codec import ( + encode_compact as encode, +) class TestCircularReferences: @@ -49,9 +66,7 @@ def test_mutual_reference_lists(self): encoded = encode(list1) # Multi-link format is used to avoid parser bug with nested self-references - expected = ( - "(obj_0: list (int 1) (int 2) obj_1)\n(obj_1: list (int 3) (int 4) obj_0)" - ) + expected = "(obj_0: list (int 1) (int 2) obj_1)\n(obj_1: list (int 3) (int 4) obj_0)" assert encoded == expected decoded = decode(encoded) @@ -170,3 +185,35 @@ def test_decoder_rejects_legacy_ref_marker(self): legacy = "(dict obj_0 ((str c2VsZg==) (ref obj_0)))" with pytest.raises(ValueError, match=r"Unknown type marker:\s*ref"): decode(legacy) + + +class TestReadableFormatAndCircularReferences: + """The readable format cannot name a shared node, so it refuses a cycle.""" + + def test_readable_format_rejects_self_referencing_dict(self): + """A dict holding itself cannot be written as a tree.""" + obj = {"name": "root"} + obj["self"] = obj + + with pytest.raises(CircularReferenceError): + encode_readable(obj) + + def test_readable_format_rejects_self_referencing_list(self): + """A list holding itself cannot be written as a tree.""" + lst = [1, 2] + lst.append(lst) + + with pytest.raises(CircularReferenceError): + encode_readable(lst) + + def test_readable_format_writes_shared_object_twice(self): + """A shared object is written once per place it appears.""" + shared = {"x": 1} + + text = encode_readable({"a": shared, "b": shared}) + decoded = decode(text) + + assert decoded == {"a": {"x": 1}, "b": {"x": 1}} + # The values are equal but no longer the same object: only the compact + # format keeps identity. + assert decoded["a"] is not decoded["b"] diff --git a/python/tests/test_create_github_release_helpers.py b/python/tests/test_create_github_release_helpers.py index 64c625e..c034e77 100644 --- a/python/tests/test_create_github_release_helpers.py +++ b/python/tests/test_create_github_release_helpers.py @@ -8,9 +8,7 @@ # Load create_github_release as a regular module without requiring it to be on # the package path (it lives under scripts/ which is not part of the package). -SCRIPT_PATH = ( - Path(__file__).resolve().parent.parent / "scripts" / "create_github_release.py" -) +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "create_github_release.py" spec = importlib.util.spec_from_file_location("create_github_release", SCRIPT_PATH) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module @@ -28,14 +26,8 @@ def test_normalize_strips_bare_v() -> None: def test_normalize_handles_prerelease_and_build_metadata() -> None: - assert ( - module.normalize_release_version_for_badge("python_v1.0.0-beta.1") - == "1.0.0-beta.1" - ) - assert ( - module.normalize_release_version_for_badge("python_v1.0.0+build.7") - == "1.0.0+build.7" - ) + assert module.normalize_release_version_for_badge("python_v1.0.0-beta.1") == "1.0.0-beta.1" + assert module.normalize_release_version_for_badge("python_v1.0.0+build.7") == "1.0.0+build.7" def test_pypi_badge_uses_bare_semver() -> None: diff --git a/python/tests/test_format.py b/python/tests/test_format.py index 7e56f1f..ce00743 100644 --- a/python/tests/test_format.py +++ b/python/tests/test_format.py @@ -126,8 +126,8 @@ def test_basic_object(self): command "echo test" exitCode "0\"""" - id, obj = parse_indented(text) - assert id == "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019" + identifier, obj = parse_indented(text) + assert identifier == "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019" assert obj["uuid"] == "6dcf4c1b-ff3f-482c-95ab-711ea7d1b019" assert obj["status"] == "executed" assert obj["command"] == "echo test" @@ -138,8 +138,8 @@ def test_value_with_quotes(self): text = """test-id message 'He said "hello"'""" - id, obj = parse_indented(text) - assert id == "test-id" + identifier, obj = parse_indented(text) + assert identifier == "test-id" assert obj["message"] == 'He said "hello"' def test_empty_lines_are_skipped(self): @@ -149,8 +149,8 @@ def test_empty_lines_are_skipped(self): another "value2\"""" - id, obj = parse_indented(text) - assert id == "test-id" + identifier, obj = parse_indented(text) + assert identifier == "test-id" assert obj["key"] == "value" assert obj["another"] == "value2" From 2cef00d4990a08d3e416a3f47719f34ee46654c4 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 06:46:01 +0000 Subject: [PATCH 04/12] feat(csharp): add readable format, opt-in tracing and cross-language compact detection --- csharp/examples/BasicUsage.cs | 46 +- csharp/src/Lino.Objects.Codec/Debug.cs | 71 ++ csharp/src/Lino.Objects.Codec/ObjectCodec.cs | 173 ++++- csharp/src/Lino.Objects.Codec/Readable.cs | 665 ++++++++++++++++++ .../CircularReferencesTests.cs | 55 +- js/examples/basic_usage.js | 56 +- python/examples/basic_usage.py | 50 +- 7 files changed, 1090 insertions(+), 26 deletions(-) create mode 100644 csharp/src/Lino.Objects.Codec/Debug.cs create mode 100644 csharp/src/Lino.Objects.Codec/Readable.cs diff --git a/csharp/examples/BasicUsage.cs b/csharp/examples/BasicUsage.cs index 17d7c8f..164e906 100644 --- a/csharp/examples/BasicUsage.cs +++ b/csharp/examples/BasicUsage.cs @@ -65,11 +65,14 @@ // 5. Circular references Console.WriteLine("5. Circular References:"); +// Object identity is a property of the compact format, which names shared nodes +// with `obj_N` ids. The readable format writes a plain tree, so it rejects a +// cycle instead of silently unrolling it. // Self-referencing list var selfRef = new List(); selfRef.Add(selfRef); -encoded = Codec.Encode(selfRef); +encoded = Codec.EncodeCompact(selfRef); Console.WriteLine($" Self-referencing list encoded: {encoded}"); var decodedSelfRef = Codec.Decode(encoded) as List; @@ -79,17 +82,18 @@ // Self-referencing dictionary var selfRefDict = new Dictionary(); selfRefDict["self"] = selfRefDict; -encoded = Codec.Encode(selfRefDict); +encoded = Codec.EncodeCompact(selfRefDict); Console.WriteLine($" Self-referencing dict encoded: {encoded}"); Console.WriteLine(); // 6. Mutual references Console.WriteLine("6. Mutual References:"); +// Also a compact-format property, for the same reason. var list1 = new List { 1, 2 }; var list2 = new List { 3, 4 }; list1.Add(list2); list2.Add(list1); -encoded = Codec.Encode(list1); +encoded = Codec.EncodeCompact(list1); Console.WriteLine($" Two lists referencing each other:"); Console.WriteLine($" {encoded}"); @@ -106,4 +110,40 @@ Console.WriteLine($" -Infinity: {Codec.Encode(double.NegativeInfinity)}"); Console.WriteLine(); +// 8. Output formats +Console.WriteLine("8. Output Formats:"); +var formats = new Dictionary +{ + { "users", new List { new Dictionary { { "id", 1 }, { "name", "Alice" } } } }, + { "count", 1 } +}; + +// The default output is readable: values are written as they are. +var readable = Codec.Encode(formats); +Console.WriteLine(" Readable (default):"); +Console.WriteLine(readable); + +// The compact form carries a type marker per value and base64 encodes strings. +var compact = Codec.EncodeCompact(formats); +Console.WriteLine($" Compact: {compact}"); + +// `Decode` recognises both. +Console.WriteLine($" Compact document detected: {Codec.IsCompactNotation(compact)}"); +Console.WriteLine($" Readable document detected as compact: {Codec.IsCompactNotation(readable)}"); + +// A cycle has no readable form; use the compact one. +var cyclic = new Dictionary(); +cyclic["self"] = cyclic; +try +{ + Codec.Encode(cyclic); + Console.Error.WriteLine(" ERROR: a cycle should not be writable as readable text!"); +} +catch (CircularReferenceException e) +{ + Console.WriteLine($" Readable format rejects a cycle: {e.GetType().Name}"); +} + +Console.WriteLine(); + Console.WriteLine("=== Example completed successfully! ==="); diff --git a/csharp/src/Lino.Objects.Codec/Debug.cs b/csharp/src/Lino.Objects.Codec/Debug.cs new file mode 100644 index 0000000..971b526 --- /dev/null +++ b/csharp/src/Lino.Objects.Codec/Debug.cs @@ -0,0 +1,71 @@ +// Opt-in tracing for the codec. + +namespace Lino.Objects.Codec; + +/// +/// Opt-in tracing for the codec. +/// +/// +/// Tracing is off by default and writes nothing. It is turned on either by +/// setting the LINO_CODEC_DEBUG environment variable to a truthy value +/// (1, true, yes, on) or by calling +/// from code. +/// +/// The same switch and the same environment variable exist in the JavaScript, +/// Python and Rust implementations, so a problem can be traced the same way in +/// every language. +/// +/// +public static class CodecDebug +{ + /// Name of the environment variable that turns tracing on. + public const string DebugEnvVar = "LINO_CODEC_DEBUG"; + + private static readonly HashSet Truthy = new(StringComparer.OrdinalIgnoreCase) + { + "1", "true", "yes", "on", + }; + + private static bool? _overridden; + + /// + /// Whether tracing is currently on. + /// + /// true when trace messages are written. + public static bool IsEnabled() + { + if (_overridden is bool forced) + { + return forced; + } + var raw = Environment.GetEnvironmentVariable(DebugEnvVar); + return raw is not null && Truthy.Contains(raw.Trim()); + } + + /// + /// Turn tracing on or off from code, overriding the environment variable. + /// + /// true/false to force, null to follow the environment. + public static void SetEnabled(bool? enabled) + { + _overridden = enabled; + } + + /// + /// Write a trace message when tracing is on. + /// + /// + /// The message is built by a delegate so that building it costs nothing + /// while tracing is off, which is the normal case. + /// + /// Where the message comes from, for example readable.decode. + /// Builds the message text. + public static void Trace(string scope, Func message) + { + if (!IsEnabled()) + { + return; + } + Console.Error.WriteLine($"[lino-codec] {scope}: {message()}"); + } +} diff --git a/csharp/src/Lino.Objects.Codec/ObjectCodec.cs b/csharp/src/Lino.Objects.Codec/ObjectCodec.cs index 96e5b79..4b15c9c 100644 --- a/csharp/src/Lino.Objects.Codec/ObjectCodec.cs +++ b/csharp/src/Lino.Objects.Codec/ObjectCodec.cs @@ -107,11 +107,38 @@ private void FindObjectsNeedingIds(object? obj, Dictionary? seen = } /// - /// Encode a C# object to Links Notation format. + /// Encode a C# object into the readable, indented Links Notation format. /// + /// + /// This is the default output: keys and values are written as they are, so the + /// document can be read, grepped and reviewed without decoding anything. Use + /// for the single-line, base64 encoded form, which + /// is the only one that can represent shared nodes and circular references. + /// /// The C# object to encode - /// String representation in Links Notation format - public string Encode(object? obj) + /// String representation in readable Links Notation format + public string Encode(object? obj) => Readable.Encode(obj, Readable.DefaultIndent); + + /// + /// Encode a C# object into the readable format with a custom indentation. + /// + /// The C# object to encode + /// Indentation string used per nesting level + /// String representation in readable Links Notation format + public string Encode(object? obj, string indent) => Readable.Encode(obj, indent); + + /// + /// Encode a C# object to the compact Links Notation format. + /// + /// + /// Every value carries its type marker and every string is base64 encoded, so + /// the result is a single machine-oriented line. Unlike the readable format, + /// this one names shared nodes with obj_N ids and can therefore + /// represent circular references. + /// + /// The C# object to encode + /// String representation in compact Links Notation format + public string EncodeCompact(object? obj) { // Reset state for each encode operation _encodeMemo = new Dictionary(ReferenceEqualityComparer.Instance); @@ -149,11 +176,46 @@ public string Encode(object? obj) } /// - /// Decode Links Notation format to a C# object. + /// Encode a C# object to the compact Links Notation format. + /// + /// Kept as the historical name of . + /// The C# object to encode + /// String representation in compact Links Notation format + public string EncodeObfuscated(object? obj) => EncodeCompact(obj); + + /// + /// Decode Links Notation format to a C# object, in either format. /// + /// + /// The format is detected from the document itself, so a document written by + /// and one written by + /// are both read back here. + /// /// String in Links Notation format /// Reconstructed C# object public object? Decode(string notation) + { + if (string.IsNullOrWhiteSpace(notation)) + { + return null; + } + + if (IsCompactNotation(notation)) + { + CodecDebug.Trace("decode", () => "compact notation detected"); + return DecodeCompact(notation); + } + + CodecDebug.Trace("decode", () => "readable notation detected"); + return Readable.Decode(notation); + } + + /// + /// Decode the compact Links Notation format to a C# object. + /// + /// String in compact Links Notation format + /// Reconstructed C# object + public object? DecodeCompact(string notation) { // Reset state for each decode operation _decodeMemo = new Dictionary(); @@ -576,6 +638,67 @@ private Link EncodeValue(object? obj, HashSet? visited = null, i // Unknown type marker throw new InvalidOperationException($"Unknown type marker: {typeMarker}"); } + + /// + /// Type markers that open a compact document, across all implementations. + /// + /// + /// The languages historically disagreed on three of them — Python writes + /// None/list/dict where JavaScript and Rust write + /// null/array/object — so every implementation accepts + /// the union and can read a compact document written by any of the others. + /// + private static readonly HashSet CompactTypeMarkers = new(StringComparer.Ordinal) + { + TypeNull, "None", TypeBool, TypeInt, TypeFloat, TypeStr, + "array", TypeList, "object", TypeDict, + }; + + /// + /// Whether a document is in the compact format. + /// + /// + /// A compact document always opens with a parenthesis followed by a type + /// marker — optionally preceded by an object id, as in (obj_0: object …). + /// Readable output never does: its first line is either a lone ( or a + /// scalar. + /// + /// The document to classify + /// True when the document should be read by + public static bool IsCompactNotation(string notation) + { + var firstLine = notation + .Split('\n') + .Select(line => line.Trim()) + .FirstOrDefault(line => line.Length > 0); + + if (firstLine is null || !firstLine.StartsWith('(')) + { + return false; + } + + var tokens = firstLine[1..] + .Split(new[] { ' ', '\t', '\r', '(', ')' }, StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length == 0) + { + return false; + } + + var marker = tokens[0]; + + // Skip the `obj_N:` definition id, if present. + if (marker.EndsWith(':')) + { + if (!marker[..^1].StartsWith("obj_", StringComparison.Ordinal) || tokens.Length < 2) + { + return false; + } + marker = tokens[1]; + } + + return CompactTypeMarkers.Contains(marker); + } } /// @@ -596,18 +719,54 @@ internal class ReferenceEqualityComparer : IEqualityComparer public static class Codec { /// - /// Encode a C# object to Links Notation format. + /// Encode a C# object into the readable, indented Links Notation format. /// /// The C# object to encode - /// String representation in Links Notation format + /// String representation in readable Links Notation format public static string Encode(object? obj) => new ObjectCodec().Encode(obj); /// - /// Decode Links Notation format to a C# object. + /// Encode a C# object into the readable format with a custom indentation. + /// + /// The C# object to encode + /// Indentation string used per nesting level + /// String representation in readable Links Notation format + public static string Encode(object? obj, string indent) => new ObjectCodec().Encode(obj, indent); + + /// + /// Encode a C# object to the compact Links Notation format. + /// + /// The C# object to encode + /// String representation in compact Links Notation format + public static string EncodeCompact(object? obj) => new ObjectCodec().EncodeCompact(obj); + + /// + /// Encode a C# object to the compact Links Notation format. + /// + /// The C# object to encode + /// String representation in compact Links Notation format + public static string EncodeObfuscated(object? obj) => new ObjectCodec().EncodeCompact(obj); + + /// + /// Decode Links Notation format to a C# object, in either format. /// /// String in Links Notation format /// Reconstructed C# object public static object? Decode(string notation) => new ObjectCodec().Decode(notation); + + /// + /// Decode the compact Links Notation format to a C# object. + /// + /// String in compact Links Notation format + /// Reconstructed C# object + public static object? DecodeCompact(string notation) => new ObjectCodec().DecodeCompact(notation); + + /// + /// Whether a document is in the compact format. + /// + /// The document to classify + /// True when the document is compact + public static bool IsCompactNotation(string notation) => ObjectCodec.IsCompactNotation(notation); } /// diff --git a/csharp/src/Lino.Objects.Codec/Readable.cs b/csharp/src/Lino.Objects.Codec/Readable.cs new file mode 100644 index 0000000..36f083d --- /dev/null +++ b/csharp/src/Lino.Objects.Codec/Readable.cs @@ -0,0 +1,665 @@ +// Readable, indented Links Notation representation. + +using System.Globalization; +using System.Text; + +namespace Lino.Objects.Codec; + +/// +/// Raised when a value cannot be written because it refers back to itself. +/// +/// +/// The readable form writes a plain tree and has no place to put the obj_N +/// definition ids that name a shared node, so a cycle cannot be represented. +/// handles cycles. +/// +public class CircularReferenceException : InvalidOperationException +{ + /// Create the exception with the default message. + public CircularReferenceException() + : base("Cannot write a circular reference in the readable format; " + + "use EncodeCompact, which names shared nodes with obj_N ids") + { + } + + /// Create the exception with a message. + /// Why the value could not be written + public CircularReferenceException(string message) : base(message) + { + } + + /// Create the exception with a message and an inner exception. + /// Why the value could not be written + /// The cause + public CircularReferenceException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// Readable, indented Links Notation representation. +/// +/// +/// +/// This class implements the default output of : +/// a plain-text, indented projection where keys and values are written as they +/// are, so the document can be read, grepped and reviewed without decoding +/// anything. +/// +/// +/// One construct — ( ) — is used for both objects and arrays, at every +/// level including the root. What distinguishes them is the content of the lines: +/// key value pairs make an object, bare values make an array. +/// +/// +/// ( +/// type "RouterState" +/// server ( +/// host "127.0.0.1" +/// port 18878 +/// ) +/// models ( +/// "claude-haiku" +/// "claude-opus" +/// ) +/// ) +/// +/// +/// Empty containers keep their type: an empty array is () on one line, +/// while an empty object is written as ( and ) on two lines. +/// +/// +/// Only values that cannot be written as plain text are encoded: strings holding +/// control characters (including newlines and tabs, which line-based tooling and +/// CRLF normalisation would corrupt) are marked individually as +/// (base64 "…") instead of encoding the whole document. +/// +/// +public static class Readable +{ + /// Default indentation used by . + public const string DefaultIndent = " "; + + /// Marker used for values that cannot be represented as plain text. + public const string Base64Marker = "base64"; + + /// Characters that cannot appear in a bare (unquoted) reference. + private static readonly char[] QuoteChars = { '"', '\'', '`' }; + + /// Characters that force an object key to be quoted. + private static readonly char[] KeyNeedsQuotes = { '(', ')', '\'', '"', ':', '`' }; + + /// + /// Encode a value into the readable, indented Links Notation form. + /// + /// The value to encode + /// Indentation string used per nesting level + /// The readable Links Notation document + /// If the value refers back to itself + /// If the value holds a type this format cannot write + public static string Encode(object? value, string indent) + { + var output = new StringBuilder(); + WriteValue(value, indent, 0, output, new HashSet(ReferenceEqualityComparer.Instance)); + return output.ToString(); + } + + /// + /// Encode a value using the default indentation. + /// + /// The value to encode + /// The readable Links Notation document + public static string Encode(object? value) => Encode(value, DefaultIndent); + + /// + /// Decode the readable, indented Links Notation form back into a value. + /// + /// The readable Links Notation document + /// The reconstructed value + /// If the document is not well formed + public static object? Decode(string text) + { + var tokens = Tokenize(text); + CodecDebug.Trace("readable.decode", () => $"{tokens.Count} tokens"); + var cursor = new Cursor(tokens); + var rows = cursor.ParseRows(true); + + if (cursor.Pos < tokens.Count) + { + throw new FormatException("unexpected ')' in readable notation"); + } + + // A document holding a single value (for example `42`) is that value. + if (rows.Count == 1 && rows[0].Count == 1) + { + return NodeToValue(rows[0][0]); + } + + return RowsToValue(rows, true); + } + + // === Encoding === + + private static void WriteValue(object? value, string indent, int level, StringBuilder output, HashSet path) + { + if (value is IDictionary dict) + { + EnterPath(dict, path); + if (dict.Count == 0) + { + // An empty object spans two lines; `()` on one line is an empty array. + output.Append("(\n"); + PushIndent(indent, level, output); + output.Append(')'); + } + else + { + output.Append('('); + foreach (var pair in dict) + { + output.Append('\n'); + PushIndent(indent, level + 1, output); + output.Append(FormatKey(pair.Key)); + output.Append(' '); + WriteValue(pair.Value, indent, level + 1, output, path); + } + output.Append('\n'); + PushIndent(indent, level, output); + output.Append(')'); + } + path.Remove(dict); + return; + } + + if (value is System.Collections.IEnumerable items and not string) + { + EnterPath(items, path); + var list = items.Cast().ToList(); + if (list.Count == 0) + { + output.Append("()"); + } + else + { + output.Append('('); + foreach (var item in list) + { + output.Append('\n'); + PushIndent(indent, level + 1, output); + WriteValue(item, indent, level + 1, output, path); + } + output.Append('\n'); + PushIndent(indent, level, output); + output.Append(')'); + } + path.Remove(items); + return; + } + + output.Append(FormatScalar(value)); + } + + /// + /// Mark a container as being written, so a reference back to it is caught. + /// + /// + /// Only the containers on the way down are tracked: the same object appearing + /// twice side by side is written twice, which reads back as two equal values. + /// + private static void EnterPath(object container, HashSet path) + { + if (!path.Add(container)) + { + throw new CircularReferenceException(); + } + } + + private static void PushIndent(string indent, int level, StringBuilder output) + { + for (int i = 0; i < level; i++) + { + output.Append(indent); + } + } + + /// + /// Format a scalar value. Strings are quoted, everything else stays bare so + /// that its type is recoverable when reading the document back. + /// + private static string FormatScalar(object? value) + { + return value switch + { + null => "null", + // Written in lower case in every language, so the output is identical. + bool b => b ? "true" : "false", + string s => FormatString(s), + sbyte or byte or short or ushort or int or uint or long or ulong => + Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null", + float f => FormatFloat(f), + double d => FormatFloat(d), + decimal m => m.ToString(CultureInfo.InvariantCulture), + _ => throw new InvalidOperationException($"Unsupported type: {value.GetType().Name}"), + }; + } + + private static string FormatFloat(double value) + { + if (double.IsNaN(value)) + { + return "NaN"; + } + if (double.IsPositiveInfinity(value)) + { + return "Infinity"; + } + if (double.IsNegativeInfinity(value)) + { + return "-Infinity"; + } + + // The decimal point is what tells a float apart from an integer when the + // document is read back, so a whole float keeps one. + var text = value.ToString("R", CultureInfo.InvariantCulture); + if (text.IndexOfAny(new[] { '.', 'e', 'E' }) < 0) + { + text += ".0"; + } + return text; + } + + /// + /// Format a string value: quoted plain text, or an individually marked + /// base64 payload when the text cannot be written literally. + /// + private static string FormatString(string value) + { + if (NeedsEncoding(value)) + { + var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + return $"({Base64Marker} {Quote(payload)})"; + } + return Quote(value); + } + + /// + /// A value can be written as text unless it contains control characters: + /// newlines break the line structure and CRLF normalisation would rewrite them. + /// + private static bool NeedsEncoding(string value) + { + foreach (var c in value) + { + // Unicode category Cc: the C0 and C1 control ranges. + if (c <= 0x1f || (c >= 0x7f && c <= 0x9f)) + { + return true; + } + } + return false; + } + + private static string Quote(string value) + { + if (!value.Contains('"')) + { + return $"\"{value}\""; + } + if (!value.Contains('\'')) + { + return $"'{value}'"; + } + // Both quote styles are present: double the double quotes, as the parser expects. + return $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + } + + /// Format an object key. Keys are bare when they read as plain identifiers. + private static string FormatKey(string key) + { + var plain = key.Length > 0 + && key != Base64Marker + && !NeedsEncoding(key) + && !key.Any(c => char.IsWhiteSpace(c) || KeyNeedsQuotes.Contains(c)); + + return plain ? key : FormatString(key); + } + + // === Decoding === + + private enum TokenKind + { + Open, + Close, + Newline, + Ref, + } + + private readonly record struct Token(TokenKind Kind, string Value, bool Quoted); + + /// + /// A parsed element of the readable form: either a reference (remembering + /// whether it was quoted, which is what distinguishes a string from a number) + /// or a link. + /// + private sealed class Node + { + public bool IsRef { get; init; } + public string Value { get; init; } = string.Empty; + public bool Quoted { get; init; } + public List> Rows { get; init; } = new(); + public bool Multiline { get; init; } + } + + /// + /// Split a document into tokens: parentheses, newlines and references. + /// + private static List Tokenize(string text) + { + var chars = text.ToCharArray(); + var tokens = new List(); + int i = 0; + + while (i < chars.Length) + { + var c = chars[i]; + + if (c == '\n') + { + tokens.Add(new Token(TokenKind.Newline, string.Empty, false)); + i++; + } + else if (char.IsWhiteSpace(c)) + { + i++; + } + else if (c == '(') + { + tokens.Add(new Token(TokenKind.Open, string.Empty, false)); + i++; + } + else if (c == ')') + { + tokens.Add(new Token(TokenKind.Close, string.Empty, false)); + i++; + } + else if (QuoteChars.Contains(c)) + { + var (value, next) = ReadQuoted(chars, i, c); + tokens.Add(new Token(TokenKind.Ref, value, true)); + i = next; + } + else + { + int start = i; + while (i < chars.Length + && !char.IsWhiteSpace(chars[i]) + && chars[i] != '(' + && chars[i] != ')' + && !QuoteChars.Contains(chars[i])) + { + i++; + } + tokens.Add(new Token(TokenKind.Ref, new string(chars, start, i - start), false)); + } + } + + return tokens; + } + + /// Read a quoted reference, where a doubled quote character means a literal one. + private static (string Value, int Next) ReadQuoted(char[] chars, int start, char quoteChar) + { + var value = new StringBuilder(); + int i = start + 1; + + while (i < chars.Length) + { + if (chars[i] == quoteChar) + { + if (i + 1 < chars.Length && chars[i + 1] == quoteChar) + { + value.Append(quoteChar); + i += 2; + continue; + } + return (value.ToString(), i + 1); + } + value.Append(chars[i]); + i++; + } + + throw new FormatException( + $"unterminated quoted value starting at character {start.ToString(CultureInfo.InvariantCulture)}"); + } + + /// Cursor over the token stream, turning tokens into nodes and rows. + private sealed class Cursor + { + private readonly List _tokens; + + public Cursor(List tokens) + { + _tokens = tokens; + } + + public int Pos { get; private set; } + + /// + /// Parse rows until the matching ) (or the end of input at the top + /// level). A row is one line: the values written between two newlines. + /// + public List> ParseRows(bool topLevel) + { + var rows = new List>(); + var row = new List(); + + while (Pos < _tokens.Count) + { + var token = _tokens[Pos]; + + if (token.Kind == TokenKind.Close) + { + if (topLevel) + { + break; + } + Pos++; + if (row.Count > 0) + { + rows.Add(row); + } + return rows; + } + + if (token.Kind == TokenKind.Newline) + { + Pos++; + if (row.Count > 0) + { + rows.Add(row); + row = new List(); + } + continue; + } + + row.Add(ParseNode()); + } + + if (!topLevel) + { + throw new FormatException("unterminated '(' in readable notation"); + } + + if (row.Count > 0) + { + rows.Add(row); + } + return rows; + } + + private Node ParseNode() + { + var token = _tokens[Pos]; + + if (token.Kind == TokenKind.Ref) + { + Pos++; + return new Node { IsRef = true, Value = token.Value, Quoted = token.Quoted }; + } + + if (token.Kind == TokenKind.Open) + { + Pos++; + var multiline = LinkIsMultiline(); + var rows = ParseRows(false); + return new Node { IsRef = false, Rows = rows, Multiline = multiline }; + } + + throw new FormatException("unexpected token in readable notation"); + } + + /// + /// Whether the link that just opened spans more than one line, which is + /// what tells an empty object ((\n)) from an empty array (()). + /// + private bool LinkIsMultiline() + { + for (int i = Pos; i < _tokens.Count; i++) + { + if (_tokens[i].Kind == TokenKind.Close) + { + return false; + } + if (_tokens[i].Kind == TokenKind.Newline) + { + return true; + } + } + return false; + } + } + + private static object? NodeToValue(Node node) => + node.IsRef ? RefToValue(node.Value, node.Quoted) : RowsToValue(node.Rows, node.Multiline); + + private static object? RowsToValue(List> rows, bool multiline) + { + if (rows.Count == 0) + { + return multiline ? new Dictionary() : new List(); + } + + var marked = DecodeMarkedValue(rows); + if (marked is not null) + { + return marked; + } + + // `key value` on every line makes an object; anything else is a list of values. + var isObject = rows.All(row => row.Count == 2 && row[0].IsRef); + + if (isObject) + { + var result = new Dictionary(); + foreach (var row in rows) + { + result[row[0].Value] = NodeToValue(row[1]); + } + return result; + } + + var items = new List(); + foreach (var row in rows) + { + foreach (var node in row) + { + items.Add(NodeToValue(node)); + } + } + return items; + } + + /// + /// Recognise (base64 "…"), the individual marker for values that could + /// not be written as text. A quoted base64 key is an ordinary object + /// key, not a marker. + /// + private static string? DecodeMarkedValue(List> rows) + { + if (rows.Count != 1 || rows[0].Count != 2) + { + return null; + } + + var marker = rows[0][0]; + var payload = rows[0][1]; + + if (!marker.IsRef || marker.Quoted || marker.Value != Base64Marker) + { + return null; + } + if (!payload.IsRef || !payload.Quoted) + { + return null; + } + + try + { + return Encoding.UTF8.GetString(Convert.FromBase64String(payload.Value)); + } + catch (FormatException e) + { + throw new FormatException($"invalid base64 value: {payload.Value}", e); + } + } + + /// + /// Convert a reference to a value. Quoted references are always strings; bare + /// references keep the type they were written with. + /// + private static object? RefToValue(string value, bool quoted) + { + if (quoted) + { + return value; + } + + switch (value) + { + case "null": + return null; + case "true": + return true; + case "false": + return false; + case "NaN": + return double.NaN; + case "Infinity": + return double.PositiveInfinity; + case "-Infinity": + return double.NegativeInfinity; + default: + break; + } + + if (long.TryParse(value, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var parsedLong)) + { + // The compact codec reads whole numbers as `int` where they fit, so + // the two formats decode the same document to the same types. + if (parsedLong >= int.MinValue && parsedLong <= int.MaxValue) + { + return (int)parsedLong; + } + return parsedLong; + } + + if (value.IndexOfAny(new[] { '.', 'e', 'E' }) >= 0 + && double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedDouble)) + { + return parsedDouble; + } + + return value; + } +} diff --git a/csharp/tests/Lino.Objects.Codec.Tests/CircularReferencesTests.cs b/csharp/tests/Lino.Objects.Codec.Tests/CircularReferencesTests.cs index be0acab..86df6cf 100644 --- a/csharp/tests/Lino.Objects.Codec.Tests/CircularReferencesTests.cs +++ b/csharp/tests/Lino.Objects.Codec.Tests/CircularReferencesTests.cs @@ -1,4 +1,9 @@ // Tests for encoding/decoding objects with circular references. +// +// Object identity — shared nodes and cycles — is a property of the compact +// format, which names nodes with `obj_N` ids. These tests therefore encode with +// `Codec.EncodeCompact`; `Codec.Decode` reads either format back. The readable +// format writes a plain tree and rejects cycles, which the last tests cover. using Xunit; using Lino.Objects.Codec; @@ -16,7 +21,7 @@ public void Roundtrip_SelfReferencingList_PreservesReference() var lst = new List(); lst.Add(lst); - var encoded = Codec.Encode(lst); + var encoded = Codec.EncodeCompact(lst); // Verify correct Links Notation format with built-in self-reference syntax Assert.Equal("(obj_0: list obj_0)", encoded); @@ -34,7 +39,7 @@ public void Roundtrip_SelfReferencingDict_PreservesReference() var d = new Dictionary(); d["self"] = d; - var encoded = Codec.Encode(d); + var encoded = Codec.EncodeCompact(d); // Verify correct Links Notation format with built-in self-reference syntax Assert.Equal("(obj_0: dict ((str c2VsZg==) obj_0))", encoded); @@ -55,7 +60,7 @@ public void Roundtrip_MutualReferenceLists_PreservesReferences() list1.Add(list2); list2.Add(list1); - var encoded = Codec.Encode(list1); + var encoded = Codec.EncodeCompact(list1); // Multi-link format is used to avoid parser bug with nested self-references var expected = "(obj_0: list (int 1) (int 2) obj_1)\n(obj_1: list (int 3) (int 4) obj_0)"; Assert.Equal(expected, encoded); @@ -84,7 +89,7 @@ public void Roundtrip_MutualReferenceDicts_PreservesReferences() dict1["other"] = dict2; dict2["other"] = dict1; - var encoded = Codec.Encode(dict1); + var encoded = Codec.EncodeCompact(dict1); var decoded = Codec.Decode(encoded); // Check the structure @@ -108,7 +113,7 @@ public void Roundtrip_ComplexCircularStructure_PreservesReferences() ((List)root["children"]!).Add(child1); ((List)root["children"]!).Add(child2); - var encoded = Codec.Encode(root); + var encoded = Codec.EncodeCompact(root); var decoded = Codec.Decode(encoded); // Check the structure @@ -135,7 +140,7 @@ public void Roundtrip_ListWithMultipleReferencesToSameObject_PreservesIdentity() var shared = new Dictionary { { "shared", "value" } }; var lst = new List { shared, shared, shared }; - var encoded = Codec.Encode(lst); + var encoded = Codec.EncodeCompact(lst); var decoded = Codec.Decode(encoded); // Check that all three items reference the same object @@ -159,7 +164,7 @@ public void Roundtrip_DictWithMultipleReferencesToSameObject_PreservesIdentity() { "third", shared } }; - var encoded = Codec.Encode(d); + var encoded = Codec.EncodeCompact(d); var decoded = Codec.Decode(encoded); // Check that all three values reference the same object @@ -186,7 +191,7 @@ public void Roundtrip_DeeplyNestedCircularReference_PreservesReference() // Create circular reference level4["root"] = level1; - var encoded = Codec.Encode(level1); + var encoded = Codec.EncodeCompact(level1); var decoded = Codec.Decode(encoded); // Navigate down the structure @@ -205,4 +210,38 @@ public void Roundtrip_DeeplyNestedCircularReference_PreservesReference() // Check circular reference back to root Assert.Same(decodedLevel1, decodedLevel4["root"]); } + + [Fact] + public void ReadableFormat_RejectsSelfReferencingDict() + { + var d = new Dictionary(); + d["self"] = d; + + Assert.Throws(() => Codec.Encode(d)); + } + + [Fact] + public void ReadableFormat_RejectsSelfReferencingList() + { + var lst = new List(); + lst.Add(lst); + + Assert.Throws(() => Codec.Encode(lst)); + } + + [Fact] + public void ReadableFormat_WritesSharedObjectTwice() + { + var shared = new Dictionary { ["shared"] = "value" }; + var root = new Dictionary { ["a"] = shared, ["b"] = shared }; + + var decoded = Assert.IsType>(Codec.Decode(Codec.Encode(root))); + var a = Assert.IsType>(decoded["a"]); + var b = Assert.IsType>(decoded["b"]); + + // Equal in content, but no longer the same node: the readable format has + // no place for the `obj_N` id that names a shared one. + Assert.Equal(a, b); + Assert.NotSame(a, b); + } } diff --git a/js/examples/basic_usage.js b/js/examples/basic_usage.js index 9c87872..fe1f2c7 100644 --- a/js/examples/basic_usage.js +++ b/js/examples/basic_usage.js @@ -2,7 +2,13 @@ * Basic usage examples for lino-objects-codec. */ -import { encode, decode, formatIndented, parseIndented } from '../src/index.js'; +import { + encode, + encodeCompact, + decode, + formatIndented, + parseIndented, +} from '../src/index.js'; function runReadableIndentedExample() { console.log('1. Readable Indented Data:'); @@ -97,12 +103,15 @@ function runTypedNestedStructuresExample() { function runCircularReferencesExample() { console.log('\n5. Circular References:'); + // Object identity is a property of the compact format, which names shared + // nodes with `obj_N` ids. The readable format writes a plain tree, so it + // rejects a cycle instead of silently unrolling it. // Self-referencing array const arr = [1, 2, 3]; arr.push(arr); console.log(' Created self-referencing array'); - const encodedCircular = encode({ obj: arr }); + const encodedCircular = encodeCompact({ obj: arr }); console.log(` Encoded: ${encodedCircular}`); const decodedCircular = decode({ notation: encodedCircular }); console.log( @@ -119,7 +128,7 @@ function runCircularReferencesExample() { const obj = { name: 'root' }; obj.self = obj; console.log('\n Created self-referencing object'); - const encodedObjectCircular = encode({ obj }); + const encodedObjectCircular = encodeCompact({ obj }); console.log(` Encoded: ${encodedObjectCircular}`); const decodedObjectCircular = decode({ notation: encodedObjectCircular }); console.log(` Decoded correctly: ${decodedObjectCircular.name === 'root'}`); @@ -133,10 +142,11 @@ function runCircularReferencesExample() { function runSharedReferencesExample() { console.log('\n6. Shared Object References:'); + // Also a compact-format property, for the same reason. const shared = { shared: 'data', value: 42 }; const container = { first: shared, second: shared, third: shared }; console.log(' Created container with 3 references to same object'); - const encodedShared = encode({ obj: container }); + const encodedShared = encodeCompact({ obj: container }); console.log(` Encoded: ${encodedShared}`); const decodedShared = decode({ notation: encodedShared }); const allSame = @@ -159,6 +169,43 @@ function runSharedReferencesExample() { } } +function runOutputFormatsExample() { + console.log('\n7. Output Formats:'); + const data = { + users: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + count: 2, + }; + + // The default output is readable: values are written as they are. + const readable = encode({ obj: data }); + console.log(' Readable (default):'); + console.log(readable); + + // The compact form carries a type marker per value and base64 encodes strings. + const compact = encodeCompact({ obj: data }); + console.log(` Compact: ${compact}`); + + // `decode` recognises both. + const fromReadable = JSON.stringify(decode({ notation: readable })); + const fromCompact = JSON.stringify(decode({ notation: compact })); + console.log( + ` Both decode back to the same value: ${fromReadable === JSON.stringify(data) && fromCompact === JSON.stringify(data)}` + ); + + // A cycle has no readable form; use the compact one. + const cyclic = {}; + cyclic.self = cyclic; + try { + encode({ obj: cyclic }); + console.error(' ERROR: a cycle should not be writable as readable text!'); + } catch (error) { + console.log(` Readable format rejects a cycle: ${error.name}`); + } +} + function main() { console.log('=== Link Notation Objects Codec Examples ===\n'); @@ -168,6 +215,7 @@ function main() { runTypedNestedStructuresExample(); runCircularReferencesExample(); runSharedReferencesExample(); + runOutputFormatsExample(); console.log('\n=== All examples completed successfully! ==='); } diff --git a/python/examples/basic_usage.py b/python/examples/basic_usage.py index 3dd8495..66a5fec 100644 --- a/python/examples/basic_usage.py +++ b/python/examples/basic_usage.py @@ -1,6 +1,11 @@ """Basic usage examples for lino-objects-codec.""" -from link_notation_objects_codec import encode, decode +from link_notation_objects_codec import ( + CircularReferenceError, + decode, + encode, + encode_compact, +) def main(): @@ -60,12 +65,15 @@ def main(): # Example 4: Circular references print("\n4. Circular References:") + # Object identity is a property of the compact format, which names shared + # nodes with ``obj_N`` ids. The readable format writes a plain tree, so it + # rejects a cycle instead of silently unrolling it. # Self-referencing list lst = [1, 2, 3] lst.append(lst) print(" Created self-referencing list") - encoded_circular = encode(lst) + encoded_circular = encode_compact(lst) print(f" Encoded: {encoded_circular}") decoded_circular = decode(encoded_circular) print(f" Decoded correctly: {decoded_circular[:3] == [1, 2, 3]}") @@ -76,7 +84,7 @@ def main(): d = {"name": "root"} d["self"] = d print("\n Created self-referencing dict") - encoded_dict_circular = encode(d) + encoded_dict_circular = encode_compact(d) print(f" Encoded: {encoded_dict_circular}") decoded_dict_circular = decode(encoded_dict_circular) print(f" Decoded correctly: {decoded_dict_circular['name'] == 'root'}") @@ -87,10 +95,11 @@ def main(): # Example 5: Shared references print("\n5. Shared Object References:") + # Also a compact-format property, for the same reason. shared = {"shared": "data", "value": 42} container = {"first": shared, "second": shared, "third": shared} print(" Created container with 3 references to same object") - encoded_shared = encode(container) + encoded_shared = encode_compact(container) print(f" Encoded: {encoded_shared}") decoded_shared = decode(encoded_shared) print( @@ -106,6 +115,39 @@ def main(): ) assert decoded_shared["second"]["modified"] is True + # Example 6: Output formats + print("\n6. Output Formats:") + data = { + "users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], + "count": 2, + } + + # The default output is readable: values are written as they are. + readable = encode(data) + print(" Readable (default):") + print(readable) + + # The compact form carries a type marker per value and base64 encodes strings. + compact = encode_compact(data) + print(f" Compact: {compact}") + + # ``decode`` recognises both. + print( + " Both decode back to the same value: " + f"{decode(readable) == data and decode(compact) == data}" + ) + assert decode(readable) == data + assert decode(compact) == data + + # A cycle has no readable form; use the compact one. + cyclic: dict[str, object] = {} + cyclic["self"] = cyclic + try: + encode(cyclic) + raise AssertionError("a cycle should not be writable as readable text") + except CircularReferenceError as error: + print(f" Readable format rejects a cycle: {type(error).__name__}") + print("\n=== All examples completed successfully! ===") From c54de9eae40c347c7c8e234177bbffa6d162480e Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 06:56:09 +0000 Subject: [PATCH 05/12] test(all): add shared cross-language readable-format conformance fixtures Add fixtures/readable-format/cases.json (39 hand-written cases from the format spec) and per-language suites that check each language encodes each value to exactly the shared text and decodes it back, so the four implementations verify each other rather than agreeing on a shared mistake. Also single-source the Python __version__ from installed distribution metadata so it never drifts from pyproject.toml. --- .../ReadableConformanceTests.cs | 181 ++++++ experiments/issue-39/build_fixtures.py | 227 +++++++ fixtures/readable-format/cases.json | 560 ++++++++++++++++++ js/tests/test_readable_conformance.test.js | 123 ++++ .../link_notation_objects_codec/__init__.py | 10 +- python/tests/test_readable_conformance.py | 85 +++ rust/Cargo.lock | 55 ++ rust/Cargo.toml | 1 + rust/tests/readable_conformance.rs | 201 +++++++ 9 files changed, 1442 insertions(+), 1 deletion(-) create mode 100644 csharp/tests/Lino.Objects.Codec.Tests/ReadableConformanceTests.cs create mode 100644 experiments/issue-39/build_fixtures.py create mode 100644 fixtures/readable-format/cases.json create mode 100644 js/tests/test_readable_conformance.test.js create mode 100644 python/tests/test_readable_conformance.py create mode 100644 rust/tests/readable_conformance.rs diff --git a/csharp/tests/Lino.Objects.Codec.Tests/ReadableConformanceTests.cs b/csharp/tests/Lino.Objects.Codec.Tests/ReadableConformanceTests.cs new file mode 100644 index 0000000..d53ac6f --- /dev/null +++ b/csharp/tests/Lino.Objects.Codec.Tests/ReadableConformanceTests.cs @@ -0,0 +1,181 @@ +// Cross-language conformance tests for the readable, indented format. +// +// The fixtures in fixtures/readable-format/cases.json are shared by the +// JavaScript, Python, Rust and C# suites. Each case is written by hand from the +// format specification, so the four implementations check each other instead of +// agreeing on a shared mistake: every language must encode `value` to exactly +// `text` and decode `text` back to exactly `value`. + +using System.Globalization; +using System.Text.Json; +using Xunit; +using Lino.Objects.Codec; + +namespace Lino.Objects.Codec.Tests; + +/// +/// Runs the shared readable-format conformance fixtures against the C# codec. +/// +public class ReadableConformanceTests +{ + /// The language id this suite answers to in a case's skip map. + private const string Language = "csharp"; + + private static readonly string FixturesPath = FindFixtures(); + + private static string FindFixtures() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, "fixtures", "readable-format", "cases.json"); + if (File.Exists(candidate)) + { + return candidate; + } + dir = dir.Parent; + } + throw new FileNotFoundException("cannot locate fixtures/readable-format/cases.json"); + } + + private static JsonElement Cases() + { + using var document = JsonDocument.Parse(File.ReadAllText(FixturesPath)); + return document.RootElement.GetProperty("cases").Clone(); + } + + /// + /// Build a C# value from the fixtures' tagged encoding. A value is a + /// single-key object naming its type, so a string "42" and the number 42 + /// stay distinguishable in JSON. + /// + private static object? Build(JsonElement value) + { + var property = value.EnumerateObject().Single(); + var payload = property.Value; + switch (property.Name) + { + case "null": + return null; + case "bool": + return payload.GetBoolean(); + case "int": + return (int)payload.GetInt64(); + case "float": + return payload.ValueKind == JsonValueKind.String + ? payload.GetString() switch + { + "NaN" => double.NaN, + "Infinity" => double.PositiveInfinity, + "-Infinity" => double.NegativeInfinity, + var other => throw new FormatException($"unknown float name {other}"), + } + : payload.GetDouble(); + case "str": + return payload.GetString(); + case "array": + return payload.EnumerateArray().Select(Build).ToList(); + case "object": + var dict = new Dictionary(); + foreach (var pair in payload.EnumerateArray()) + { + var items = pair.EnumerateArray().ToArray(); + dict[items[0].GetString()!] = Build(items[1]); + } + return dict; + default: + throw new FormatException($"unknown value tag {property.Name}"); + } + } + + /// + /// Compare two decoded values, treating NaN as equal to itself and object + /// key order as significant -- key order is part of the document. + /// + private static bool Same(object? left, object? right) + { + switch (left, right) + { + case (null, null): + return true; + case (bool a, bool b): + return a == b; + case (int a, int b): + return a == b; + case (long a, long b): + return a == b; + case (double a, double b): + return (double.IsNaN(a) && double.IsNaN(b)) || a.Equals(b); + case (string a, string b): + return a == b; + case (List a, List b): + return a.Count == b.Count && a.Zip(b).All(pair => Same(pair.First, pair.Second)); + case (IDictionary a, IDictionary b): + return a.Count == b.Count + && a.Keys.SequenceEqual(b.Keys) + && a.All(entry => Same(entry.Value, b[entry.Key])); + default: + return false; + } + } + + private static bool IsSkipped(JsonElement @case) => + @case.TryGetProperty("skip", out var skip) + && skip.TryGetProperty(Language, out _); + + public static IEnumerable AllCases() + { + foreach (var @case in Cases().EnumerateArray()) + { + yield return new object[] { @case.GetProperty("name").GetString()!, @case.Clone() }; + } + } + + [Fact] + public void EveryCaseIsEitherActiveOrSkippedWithAReason() + { + var cases = Cases().EnumerateArray().ToArray(); + Assert.NotEmpty(cases); + foreach (var @case in cases) + { + if (!@case.TryGetProperty("skip", out var skip)) + { + continue; + } + foreach (var language in skip.EnumerateObject()) + { + Assert.Contains(language.Name, new[] { "js", "python", "rust", "csharp" }); + Assert.True( + (language.Value.GetString() ?? "").Length > 20, + $"case {@case.GetProperty("name").GetString()} skips {language.Name} without explaining why"); + } + } + } + + [Theory] + [MemberData(nameof(AllCases))] + public void EncodesEachCaseToTheSharedText(string name, JsonElement @case) + { + _ = name; + if (IsSkipped(@case)) + { + return; + } + var encoded = Codec.Encode(Build(@case.GetProperty("value"))); + Assert.Equal(@case.GetProperty("text").GetString(), encoded); + } + + [Theory] + [MemberData(nameof(AllCases))] + public void DecodesEachSharedTextBackToTheCaseValue(string name, JsonElement @case) + { + _ = name; + if (IsSkipped(@case)) + { + return; + } + var expected = Build(@case.GetProperty("value")); + var decoded = Codec.Decode(@case.GetProperty("text").GetString()!); + Assert.True(Same(expected, decoded), $"case {name} decoded to a different value"); + } +} diff --git a/experiments/issue-39/build_fixtures.py b/experiments/issue-39/build_fixtures.py new file mode 100644 index 0000000..0334d49 --- /dev/null +++ b/experiments/issue-39/build_fixtures.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Build ``fixtures/readable-format/cases.json``, the cross-language fixture set. + +Every ``text`` below is written by hand from the format specification, not taken +from an implementation's output, so that the four conformance test suites check +each other rather than agreeing on a shared mistake. + +Run with ``python3 experiments/issue-39/build_fixtures.py`` from the repository +root; it only serialises the table below to JSON. +""" + +import json +from pathlib import Path + +NULL = {"null": True} + + +def b(value): + return {"bool": value} + + +def i(value): + return {"int": value} + + +def f(value): + return {"float": value} + + +def s(value): + return {"str": value} + + +def arr(*items): + return {"array": list(items)} + + +def obj(*pairs): + return {"object": [list(pair) for pair in pairs]} + + +# A language listed here cannot represent the case; the reason is asserted in the +# conformance suites, which skip exactly these cases and no others. +JS_WHOLE_FLOAT = { + "js": "JavaScript has one number type, so 2.0 and 2 are the same value and " + "the trailing '.0' cannot be recovered when encoding" +} + +CASES = [ + # === Scalars at the root === + {"name": "null_scalar", "value": NULL, "text": "null"}, + {"name": "bool_true", "value": b(True), "text": "true"}, + {"name": "bool_false", "value": b(False), "text": "false"}, + {"name": "int_positive", "value": i(42), "text": "42"}, + {"name": "int_negative", "value": i(-7), "text": "-7"}, + {"name": "int_zero", "value": i(0), "text": "0"}, + {"name": "float_fraction", "value": f(3.5), "text": "3.5"}, + {"name": "float_whole", "value": f(2.0), "text": "2.0", "skip": JS_WHOLE_FLOAT}, + {"name": "float_negative", "value": f(-0.5), "text": "-0.5"}, + {"name": "float_nan", "value": f("NaN"), "text": "NaN"}, + {"name": "float_infinity", "value": f("Infinity"), "text": "Infinity"}, + {"name": "float_negative_infinity", "value": f("-Infinity"), "text": "-Infinity"}, + {"name": "string_plain", "value": s("root"), "text": '"root"'}, + {"name": "string_empty", "value": s(""), "text": '""'}, + {"name": "string_with_spaces", "value": s("with spaces"), "text": '"with spaces"'}, + {"name": "string_apostrophe", "value": s("it's"), "text": '"it\'s"'}, + { + "name": "string_double_quote", + "value": s('he said "hello"'), + "text": "'he said \"hello\"'", + }, + { + "name": "string_both_quote_kinds", + "value": s("both \"kinds\" of 'quotes'"), + "text": "\"both \"\"kinds\"\" of 'quotes'\"", + }, + { + "name": "string_unicode", + "value": s("unicode: 你好世界 🌍"), + "text": '"unicode: 你好世界 🌍"', + }, + { + "name": "string_parens_and_colon", + "value": s("parens (and) colons: yes"), + "text": '"parens (and) colons: yes"', + }, + # Quoting is what keeps a numeric or boolean string from decoding as a number. + {"name": "string_numeric", "value": s("18878"), "text": '"18878"'}, + {"name": "string_boolean", "value": s("true"), "text": '"true"'}, + # === Values that cannot be written as text === + { + "name": "string_with_newline", + "value": s("line1\nline2"), + "text": '(base64 "bGluZTEKbGluZTI=")', + }, + {"name": "string_with_tab", "value": s("a\tb"), "text": '(base64 "YQli")'}, + # === Empty containers keep their type === + {"name": "empty_array", "value": arr(), "text": "()"}, + {"name": "empty_object", "value": obj(), "text": "(\n)"}, + # === Containers === + { + "name": "array_of_scalars", + "value": arr(i(1), s("two"), b(True), NULL), + "text": '(\n 1\n "two"\n true\n null\n)', + }, + { + "name": "object_of_scalars", + "value": obj(("name", s("Alice")), ("age", i(30))), + "text": '(\n name "Alice"\n age 30\n)', + }, + { + "name": "nested_empty_containers", + "value": obj(("empty_array", arr()), ("empty_object", obj())), + "text": "(\n empty_array ()\n empty_object (\n )\n)", + }, + { + "name": "array_of_objects_keeps_record_boundaries", + "value": arr( + obj(("id", s("1")), ("label", s("one"))), + obj(("id", s("2")), ("label", s("two"))), + ), + "text": '(\n (\n id "1"\n label "one"\n )\n (\n id "2"\n label "two"\n )\n)', + }, + { + "name": "array_of_arrays", + "value": arr(arr(i(1), i(2)), arr(i(3))), + "text": "(\n (\n 1\n 2\n )\n (\n 3\n )\n)", + }, + { + "name": "single_pair_object_is_not_a_two_element_array", + "value": obj(("key", s("value"))), + "text": '(\n key "value"\n)', + }, + { + "name": "two_element_array_is_not_a_single_pair_object", + "value": arr(s("key"), s("value")), + "text": '(\n "key"\n "value"\n)', + }, + # === Keys === + { + "name": "keys_that_need_quoting", + "value": obj( + ("two words", i(1)), + ("", i(2)), + ("base64", i(3)), + ("with:colon", i(4)), + ('with"quote', i(5)), + ), + "text": '(\n "two words" 1\n "" 2\n "base64" 3\n "with:colon" 4\n' + " 'with\"quote' 5\n)", + }, + { + "name": "base64_key_with_plain_value_is_not_a_marker", + "value": obj(("base64", s("plain text"))), + "text": '(\n "base64" "plain text"\n)', + }, + # === The document from issue #37 === + { + "name": "documented_router_state", + "value": obj( + ("type", s("RouterState")), + ("server", obj(("host", s("127.0.0.1")), ("port", i(18878)))), + ("models", arr(s("claude-haiku"), s("claude-opus"))), + ), + "text": '(\n type "RouterState"\n server (\n host "127.0.0.1"\n' + ' port 18878\n )\n models (\n "claude-haiku"\n' + ' "claude-opus"\n )\n)', + }, + { + "name": "mixed_types_in_one_object", + "value": obj( + ("int", i(-7)), + ("float", f(3.5)), + ("whole_float", f(2.0)), + ("yes", b(True)), + ("no", b(False)), + ("nothing", NULL), + ("numeric_string", s("18878")), + ("boolean_string", s("true")), + ), + "text": "(\n int -7\n float 3.5\n whole_float 2.0\n yes true\n" + ' no false\n nothing null\n numeric_string "18878"\n' + ' boolean_string "true"\n)', + "skip": JS_WHOLE_FLOAT, + }, + { + "name": "only_unwritable_values_are_marked", + "value": obj( + ("readable", s("still visible")), + ("multiline", s("line1\nline2")), + ("tabbed", s("a\tb")), + ), + "text": '(\n readable "still visible"\n multiline (base64 "bGluZTEKbGluZTI=")\n' + ' tabbed (base64 "YQli")\n)', + }, + { + "name": "deeply_nested_objects", + "value": obj(("a", obj(("b", obj(("c", arr(i(1)))))))), + "text": "(\n a (\n b (\n c (\n 1\n )\n )\n )\n)", + }, +] + +DOCUMENT = { + "description": ( + "Shared conformance fixtures for the readable, indented Links Notation " + "format. Every implementation must encode `value` to exactly `text` and " + "decode `text` back to exactly `value`, so the four languages produce " + "byte-identical documents." + ), + "valueEncoding": ( + "A value is a single-key object naming its type: {\"null\": true}, " + '{"bool": …}, {"int": …}, {"float": … | "NaN" | "Infinity" | ' + '"-Infinity"}, {"str": …}, {"array": [value, …]} or ' + '{"object": [[key, value], …]}. Object pairs are a list, not a map, ' + "because key order is part of the document." + ), + "skip": ( + "`skip` maps a language id (js, python, rust, csharp) to the reason its " + "value model cannot represent the case. A suite skips exactly the cases " + "naming it." + ), + "cases": CASES, +} + +target = Path(__file__).resolve().parents[2] / "fixtures" / "readable-format" / "cases.json" +target.write_text(json.dumps(DOCUMENT, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +print(f"wrote {len(CASES)} cases to {target}") diff --git a/fixtures/readable-format/cases.json b/fixtures/readable-format/cases.json new file mode 100644 index 0000000..e2b3f1e --- /dev/null +++ b/fixtures/readable-format/cases.json @@ -0,0 +1,560 @@ +{ + "description": "Shared conformance fixtures for the readable, indented Links Notation format. Every implementation must encode `value` to exactly `text` and decode `text` back to exactly `value`, so the four languages produce byte-identical documents.", + "valueEncoding": "A value is a single-key object naming its type: {\"null\": true}, {\"bool\": …}, {\"int\": …}, {\"float\": … | \"NaN\" | \"Infinity\" | \"-Infinity\"}, {\"str\": …}, {\"array\": [value, …]} or {\"object\": [[key, value], …]}. Object pairs are a list, not a map, because key order is part of the document.", + "skip": "`skip` maps a language id (js, python, rust, csharp) to the reason its value model cannot represent the case. A suite skips exactly the cases naming it.", + "cases": [ + { + "name": "null_scalar", + "value": { + "null": true + }, + "text": "null" + }, + { + "name": "bool_true", + "value": { + "bool": true + }, + "text": "true" + }, + { + "name": "bool_false", + "value": { + "bool": false + }, + "text": "false" + }, + { + "name": "int_positive", + "value": { + "int": 42 + }, + "text": "42" + }, + { + "name": "int_negative", + "value": { + "int": -7 + }, + "text": "-7" + }, + { + "name": "int_zero", + "value": { + "int": 0 + }, + "text": "0" + }, + { + "name": "float_fraction", + "value": { + "float": 3.5 + }, + "text": "3.5" + }, + { + "name": "float_whole", + "value": { + "float": 2.0 + }, + "text": "2.0", + "skip": { + "js": "JavaScript has one number type, so 2.0 and 2 are the same value and the trailing '.0' cannot be recovered when encoding" + } + }, + { + "name": "float_negative", + "value": { + "float": -0.5 + }, + "text": "-0.5" + }, + { + "name": "float_nan", + "value": { + "float": "NaN" + }, + "text": "NaN" + }, + { + "name": "float_infinity", + "value": { + "float": "Infinity" + }, + "text": "Infinity" + }, + { + "name": "float_negative_infinity", + "value": { + "float": "-Infinity" + }, + "text": "-Infinity" + }, + { + "name": "string_plain", + "value": { + "str": "root" + }, + "text": "\"root\"" + }, + { + "name": "string_empty", + "value": { + "str": "" + }, + "text": "\"\"" + }, + { + "name": "string_with_spaces", + "value": { + "str": "with spaces" + }, + "text": "\"with spaces\"" + }, + { + "name": "string_apostrophe", + "value": { + "str": "it's" + }, + "text": "\"it's\"" + }, + { + "name": "string_double_quote", + "value": { + "str": "he said \"hello\"" + }, + "text": "'he said \"hello\"'" + }, + { + "name": "string_both_quote_kinds", + "value": { + "str": "both \"kinds\" of 'quotes'" + }, + "text": "\"both \"\"kinds\"\" of 'quotes'\"" + }, + { + "name": "string_unicode", + "value": { + "str": "unicode: 你好世界 🌍" + }, + "text": "\"unicode: 你好世界 🌍\"" + }, + { + "name": "string_parens_and_colon", + "value": { + "str": "parens (and) colons: yes" + }, + "text": "\"parens (and) colons: yes\"" + }, + { + "name": "string_numeric", + "value": { + "str": "18878" + }, + "text": "\"18878\"" + }, + { + "name": "string_boolean", + "value": { + "str": "true" + }, + "text": "\"true\"" + }, + { + "name": "string_with_newline", + "value": { + "str": "line1\nline2" + }, + "text": "(base64 \"bGluZTEKbGluZTI=\")" + }, + { + "name": "string_with_tab", + "value": { + "str": "a\tb" + }, + "text": "(base64 \"YQli\")" + }, + { + "name": "empty_array", + "value": { + "array": [] + }, + "text": "()" + }, + { + "name": "empty_object", + "value": { + "object": [] + }, + "text": "(\n)" + }, + { + "name": "array_of_scalars", + "value": { + "array": [ + { + "int": 1 + }, + { + "str": "two" + }, + { + "bool": true + }, + { + "null": true + } + ] + }, + "text": "(\n 1\n \"two\"\n true\n null\n)" + }, + { + "name": "object_of_scalars", + "value": { + "object": [ + [ + "name", + { + "str": "Alice" + } + ], + [ + "age", + { + "int": 30 + } + ] + ] + }, + "text": "(\n name \"Alice\"\n age 30\n)" + }, + { + "name": "nested_empty_containers", + "value": { + "object": [ + [ + "empty_array", + { + "array": [] + } + ], + [ + "empty_object", + { + "object": [] + } + ] + ] + }, + "text": "(\n empty_array ()\n empty_object (\n )\n)" + }, + { + "name": "array_of_objects_keeps_record_boundaries", + "value": { + "array": [ + { + "object": [ + [ + "id", + { + "str": "1" + } + ], + [ + "label", + { + "str": "one" + } + ] + ] + }, + { + "object": [ + [ + "id", + { + "str": "2" + } + ], + [ + "label", + { + "str": "two" + } + ] + ] + } + ] + }, + "text": "(\n (\n id \"1\"\n label \"one\"\n )\n (\n id \"2\"\n label \"two\"\n )\n)" + }, + { + "name": "array_of_arrays", + "value": { + "array": [ + { + "array": [ + { + "int": 1 + }, + { + "int": 2 + } + ] + }, + { + "array": [ + { + "int": 3 + } + ] + } + ] + }, + "text": "(\n (\n 1\n 2\n )\n (\n 3\n )\n)" + }, + { + "name": "single_pair_object_is_not_a_two_element_array", + "value": { + "object": [ + [ + "key", + { + "str": "value" + } + ] + ] + }, + "text": "(\n key \"value\"\n)" + }, + { + "name": "two_element_array_is_not_a_single_pair_object", + "value": { + "array": [ + { + "str": "key" + }, + { + "str": "value" + } + ] + }, + "text": "(\n \"key\"\n \"value\"\n)" + }, + { + "name": "keys_that_need_quoting", + "value": { + "object": [ + [ + "two words", + { + "int": 1 + } + ], + [ + "", + { + "int": 2 + } + ], + [ + "base64", + { + "int": 3 + } + ], + [ + "with:colon", + { + "int": 4 + } + ], + [ + "with\"quote", + { + "int": 5 + } + ] + ] + }, + "text": "(\n \"two words\" 1\n \"\" 2\n \"base64\" 3\n \"with:colon\" 4\n 'with\"quote' 5\n)" + }, + { + "name": "base64_key_with_plain_value_is_not_a_marker", + "value": { + "object": [ + [ + "base64", + { + "str": "plain text" + } + ] + ] + }, + "text": "(\n \"base64\" \"plain text\"\n)" + }, + { + "name": "documented_router_state", + "value": { + "object": [ + [ + "type", + { + "str": "RouterState" + } + ], + [ + "server", + { + "object": [ + [ + "host", + { + "str": "127.0.0.1" + } + ], + [ + "port", + { + "int": 18878 + } + ] + ] + } + ], + [ + "models", + { + "array": [ + { + "str": "claude-haiku" + }, + { + "str": "claude-opus" + } + ] + } + ] + ] + }, + "text": "(\n type \"RouterState\"\n server (\n host \"127.0.0.1\"\n port 18878\n )\n models (\n \"claude-haiku\"\n \"claude-opus\"\n )\n)" + }, + { + "name": "mixed_types_in_one_object", + "value": { + "object": [ + [ + "int", + { + "int": -7 + } + ], + [ + "float", + { + "float": 3.5 + } + ], + [ + "whole_float", + { + "float": 2.0 + } + ], + [ + "yes", + { + "bool": true + } + ], + [ + "no", + { + "bool": false + } + ], + [ + "nothing", + { + "null": true + } + ], + [ + "numeric_string", + { + "str": "18878" + } + ], + [ + "boolean_string", + { + "str": "true" + } + ] + ] + }, + "text": "(\n int -7\n float 3.5\n whole_float 2.0\n yes true\n no false\n nothing null\n numeric_string \"18878\"\n boolean_string \"true\"\n)", + "skip": { + "js": "JavaScript has one number type, so 2.0 and 2 are the same value and the trailing '.0' cannot be recovered when encoding" + } + }, + { + "name": "only_unwritable_values_are_marked", + "value": { + "object": [ + [ + "readable", + { + "str": "still visible" + } + ], + [ + "multiline", + { + "str": "line1\nline2" + } + ], + [ + "tabbed", + { + "str": "a\tb" + } + ] + ] + }, + "text": "(\n readable \"still visible\"\n multiline (base64 \"bGluZTEKbGluZTI=\")\n tabbed (base64 \"YQli\")\n)" + }, + { + "name": "deeply_nested_objects", + "value": { + "object": [ + [ + "a", + { + "object": [ + [ + "b", + { + "object": [ + [ + "c", + { + "array": [ + { + "int": 1 + } + ] + } + ] + ] + } + ] + ] + } + ] + ] + }, + "text": "(\n a (\n b (\n c (\n 1\n )\n )\n )\n)" + } + ] +} diff --git a/js/tests/test_readable_conformance.test.js b/js/tests/test_readable_conformance.test.js new file mode 100644 index 0000000..5b96740 --- /dev/null +++ b/js/tests/test_readable_conformance.test.js @@ -0,0 +1,123 @@ +/** + * Cross-language conformance tests for the readable format. + * + * The cases live in `fixtures/readable-format/cases.json` at the repository root + * and are shared by the JavaScript, Python, Rust and C# suites: every + * implementation has to encode the same value to exactly the same text, which is + * what keeps the four outputs byte-identical. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { encode, decode } from '../src/index.js'; + +const LANGUAGE = 'js'; +const LANGUAGES = new Set(['js', 'python', 'rust', 'csharp']); + +const FIXTURES = join( + dirname(dirname(dirname(fileURLToPath(import.meta.url)))), + 'fixtures', + 'readable-format', + 'cases.json' +); + +const SPECIAL_FLOATS = new Map([ + ['NaN', NaN], + ['Infinity', Infinity], + ['-Infinity', -Infinity], +]); + +const { cases } = JSON.parse(readFileSync(FIXTURES, 'utf-8')); + +/** + * Turn a fixture value specification into a JavaScript value. + * @param {object} spec - The specification, a single-key object naming the type + * @returns {*} The value + */ +function build(spec) { + if ('null' in spec) { + return null; + } + if ('bool' in spec) { + return spec.bool; + } + if ('int' in spec) { + return spec.int; + } + if ('float' in spec) { + return typeof spec.float === 'string' + ? SPECIAL_FLOATS.get(spec.float) + : spec.float; + } + if ('str' in spec) { + return spec.str; + } + if ('array' in spec) { + return spec.array.map(build); + } + if ('object' in spec) { + return Object.fromEntries( + spec.object.map(([key, value]) => [key, build(value)]) + ); + } + throw new Error(`unknown value specification: ${JSON.stringify(spec)}`); +} + +/** + * Deep equality where NaN equals NaN and key order matters. + * @param {*} left - First value + * @param {*} right - Second value + * @returns {boolean} True when the two values are the same document + */ +function same(left, right) { + if (typeof left === 'number' && typeof right === 'number') { + return Object.is(left, right) || left === right; + } + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((item, index) => same(item, right[index])) + ); + } + if (left === null || right === null || typeof left !== 'object') { + return left === right; + } + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key, index) => key === rightKeys[index]) && + leftKeys.every((key) => same(left[key], right[key])) + ); +} + +test('every case is either active or skipped with a reason', () => { + for (const testCase of cases) { + for (const [language, reason] of Object.entries(testCase.skip ?? {})) { + assert.ok(LANGUAGES.has(language), `${testCase.name}: ${language}`); + assert.ok(reason, testCase.name); + } + } +}); + +for (const testCase of cases) { + if (LANGUAGE in (testCase.skip ?? {})) { + continue; + } + + test(`encode matches the shared text: ${testCase.name}`, () => { + assert.equal(encode({ obj: build(testCase.value) }), testCase.text); + }); + + test(`decode matches the shared value: ${testCase.name}`, () => { + assert.ok( + same(decode({ notation: testCase.text }), build(testCase.value)), + `${JSON.stringify(decode({ notation: testCase.text }))} != ${JSON.stringify(build(testCase.value))}` + ); + }); +} diff --git a/python/src/link_notation_objects_codec/__init__.py b/python/src/link_notation_objects_codec/__init__.py index 049b305..dfc00f7 100644 --- a/python/src/link_notation_objects_codec/__init__.py +++ b/python/src/link_notation_objects_codec/__init__.py @@ -8,6 +8,9 @@ reads both that and the compact (base64) format written by earlier versions. """ +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _installed_version + from .codec import ( ObjectCodec, decode, @@ -31,7 +34,12 @@ ReadableFormatError, ) -__version__ = "0.3.0" +try: + #: Read from the installed distribution, so this never drifts from + #: ``pyproject.toml`` -- the release pipeline bumps the version in one place. + __version__ = _installed_version("lino-objects-codec") +except PackageNotFoundError: # pragma: no cover - only when run from a source tree + __version__ = "0.0.0+unknown" __all__ = [ "ObjectCodec", "encode", diff --git a/python/tests/test_readable_conformance.py b/python/tests/test_readable_conformance.py new file mode 100644 index 0000000..706ee16 --- /dev/null +++ b/python/tests/test_readable_conformance.py @@ -0,0 +1,85 @@ +"""Cross-language conformance tests for the readable format. + +The cases live in ``fixtures/readable-format/cases.json`` at the repository root +and are shared by the JavaScript, Python, Rust and C# suites: every +implementation has to encode the same value to exactly the same text, which is +what keeps the four outputs byte-identical. +""" + +import json +import math +from pathlib import Path +from typing import Any + +import pytest + +from link_notation_objects_codec import decode, encode + +LANGUAGE = "python" + +FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" / "readable-format" / "cases.json" + +_SPECIAL_FLOATS = {"NaN": math.nan, "Infinity": math.inf, "-Infinity": -math.inf} + + +def _load_cases() -> list[dict[str, Any]]: + document = json.loads(FIXTURES.read_text(encoding="utf-8")) + return document["cases"] + + +def _build(spec: dict[str, Any]) -> Any: + """Turn a fixture value specification into a Python value.""" + if "null" in spec: + return None + if "bool" in spec: + return spec["bool"] + if "int" in spec: + return spec["int"] + if "float" in spec: + raw = spec["float"] + return _SPECIAL_FLOATS[raw] if isinstance(raw, str) else float(raw) + if "str" in spec: + return spec["str"] + if "array" in spec: + return [_build(item) for item in spec["array"]] + if "object" in spec: + return {key: _build(value) for key, value in spec["object"]} + raise AssertionError(f"unknown value specification: {spec}") + + +def _same(left: Any, right: Any) -> bool: + """Deep equality where NaN equals NaN and a bool is not an int.""" + if isinstance(left, bool) or isinstance(right, bool): + return type(left) is type(right) and left == right + if isinstance(left, float) and isinstance(right, float): + return (math.isnan(left) and math.isnan(right)) or left == right + if isinstance(left, dict) and isinstance(right, dict): + return list(left) == list(right) and all(_same(left[key], right[key]) for key in left) + if isinstance(left, list) and isinstance(right, list): + return len(left) == len(right) and all( + _same(a, b) for a, b in zip(left, right, strict=True) + ) + return type(left) is type(right) and left == right + + +CASES = _load_cases() +ACTIVE = [case for case in CASES if LANGUAGE not in case.get("skip", {})] + + +def test_every_case_is_either_active_or_skipped_with_a_reason() -> None: + for case in CASES: + for language, reason in case.get("skip", {}).items(): + assert language in {"js", "python", "rust", "csharp"}, case["name"] + assert reason, case["name"] + + +@pytest.mark.parametrize("case", ACTIVE, ids=lambda case: case["name"]) +def test_encode_matches_the_shared_text(case: dict[str, Any]) -> None: + assert encode(_build(case["value"])) == case["text"] + + +@pytest.mark.parametrize("case", ACTIVE, ids=lambda case: case["name"]) +def test_decode_matches_the_shared_value(case: dict[str, Any]) -> None: + expected = _build(case["value"]) + decoded = decode(case["text"]) + assert _same(decoded, expected), f"{decoded!r} != {expected!r}" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 04ade25..ab35a1d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,6 +8,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "links-notation" version = "0.14.0" @@ -35,6 +41,7 @@ version = "0.3.0" dependencies = [ "base64", "links-notation", + "serde_json", ] [[package]] @@ -70,6 +77,48 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "syn" version = "3.0.3" @@ -86,3 +135,9 @@ name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ce9126d..62a22ae 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,6 +16,7 @@ links-notation = "0.14.0" base64 = "0.22" [dev-dependencies] +serde_json = "1.0.149" # Clippy lints configuration [lints.clippy] diff --git a/rust/tests/readable_conformance.rs b/rust/tests/readable_conformance.rs new file mode 100644 index 0000000..768862b --- /dev/null +++ b/rust/tests/readable_conformance.rs @@ -0,0 +1,201 @@ +//! Cross-language conformance tests for the readable, indented format. +//! +//! The fixtures in `fixtures/readable-format/cases.json` are shared by the +//! JavaScript, Python, Rust and C# suites. Each case is written by hand from the +//! format specification, so the four implementations check each other instead of +//! agreeing on a shared mistake: every language must encode `value` to exactly +//! `text` and decode `text` back to exactly `value`. + +use lino_objects_codec::{decode, encode, LinoValue}; +use serde_json::Value as Json; + +/// The language id this suite answers to in a case's `skip` map. +const LANGUAGE: &str = "rust"; + +fn fixtures() -> Json { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../fixtures/readable-format/cases.json" + ); + let text = + std::fs::read_to_string(path).unwrap_or_else(|error| panic!("cannot read {path}: {error}")); + serde_json::from_str(&text).unwrap_or_else(|error| panic!("cannot parse {path}: {error}")) +} + +fn cases() -> Vec { + match fixtures()["cases"].take() { + Json::Array(cases) => cases, + other => panic!("`cases` must be an array, got {other}"), + } +} + +/// Build a `LinoValue` from the fixtures' tagged encoding. +/// +/// A value is a single-key object naming its type, so that a string `"42"` and +/// the number `42` stay distinguishable in JSON. +fn build(value: &Json) -> LinoValue { + let object = value + .as_object() + .unwrap_or_else(|| panic!("a tagged value must be an object, got {value}")); + let (tag, payload) = object + .iter() + .next() + .unwrap_or_else(|| panic!("a tagged value must have one key, got {value}")); + assert_eq!(object.len(), 1, "a tagged value must have one key: {value}"); + match tag.as_str() { + "null" => LinoValue::Null, + "bool" => LinoValue::Bool(payload.as_bool().expect("bool payload")), + "int" => LinoValue::Int(payload.as_i64().expect("int payload")), + "float" => LinoValue::Float(match payload { + Json::String(text) => match text.as_str() { + "NaN" => f64::NAN, + "Infinity" => f64::INFINITY, + "-Infinity" => f64::NEG_INFINITY, + other => panic!("unknown float name {other}"), + }, + other => other.as_f64().expect("float payload"), + }), + "str" => LinoValue::String(payload.as_str().expect("str payload").to_string()), + "array" => LinoValue::Array( + payload + .as_array() + .expect("array payload") + .iter() + .map(build) + .collect(), + ), + "object" => LinoValue::Object( + payload + .as_array() + .expect("object payload") + .iter() + .map(|pair| { + let pair = pair + .as_array() + .expect("a key/value pair is a two-element array"); + assert_eq!(pair.len(), 2, "a key/value pair has two elements"); + ( + pair[0].as_str().expect("a key is a string").to_string(), + build(&pair[1]), + ) + }) + .collect(), + ), + other => panic!("unknown value tag {other}"), + } +} + +/// Compare two values, treating NaN as equal to itself and object key order as +/// significant -- the library's own `PartialEq` compares objects unordered, but +/// key order is part of the document these fixtures pin down. +fn same(left: &LinoValue, right: &LinoValue) -> bool { + match (left, right) { + (LinoValue::Null, LinoValue::Null) => true, + (LinoValue::Bool(a), LinoValue::Bool(b)) => a == b, + (LinoValue::Int(a), LinoValue::Int(b)) => a == b, + (LinoValue::Float(a), LinoValue::Float(b)) => { + (a.is_nan() && b.is_nan()) || a.to_bits() == b.to_bits() + } + (LinoValue::String(a), LinoValue::String(b)) => a == b, + (LinoValue::Array(a), LinoValue::Array(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| same(x, y)) + } + (LinoValue::Object(a), LinoValue::Object(b)) => { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|((ak, av), (bk, bv))| ak == bk && same(av, bv)) + } + _ => false, + } +} + +fn is_skipped(case: &Json) -> bool { + case.get("skip") + .and_then(|skip| skip.get(LANGUAGE)) + .is_some() +} + +fn name(case: &Json) -> &str { + case["name"].as_str().expect("every case has a name") +} + +fn text(case: &Json) -> &str { + case["text"].as_str().expect("every case has a text") +} + +#[test] +fn every_case_is_either_active_or_skipped_with_a_reason() { + let cases = cases(); + assert!(!cases.is_empty(), "the fixtures must contain cases"); + for case in &cases { + if let Some(skip) = case.get("skip") { + let skip = skip + .as_object() + .expect("`skip` maps language ids to reasons"); + for (language, reason) in skip { + assert!( + matches!(language.as_str(), "js" | "python" | "rust" | "csharp"), + "unknown language id {language} in case {}", + name(case) + ); + let reason = reason.as_str().unwrap_or(""); + assert!( + reason.len() > 20, + "case {} skips {language} without explaining why", + name(case) + ); + } + } + } +} + +#[test] +fn encodes_every_case_to_the_shared_text() { + let mut failures = Vec::new(); + for case in cases() { + if is_skipped(&case) { + continue; + } + let encoded = encode(&build(&case["value"])); + if encoded != text(&case) { + failures.push(format!( + "{}: expected {:?}, got {:?}", + name(&case), + text(&case), + encoded + )); + } + } + assert!( + failures.is_empty(), + "encoding mismatches:\n{}", + failures.join("\n") + ); +} + +#[test] +fn decodes_every_shared_text_back_to_the_case_value() { + let mut failures = Vec::new(); + for case in cases() { + if is_skipped(&case) { + continue; + } + let expected = build(&case["value"]); + match decode(text(&case)) { + Ok(decoded) if same(&decoded, &expected) => {} + Ok(decoded) => failures.push(format!( + "{}: expected {:?}, got {:?}", + name(&case), + expected, + decoded + )), + Err(error) => failures.push(format!("{}: {error}", name(&case))), + } + } + assert!( + failures.is_empty(), + "decoding mismatches:\n{}", + failures.join("\n") + ); +} From a0346db36ed2404eb6a4aa57cbdbdf113a27b0b8 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:01:31 +0000 Subject: [PATCH 06/12] fix(all): make the compact format's booleans cross-language readable JavaScript and Rust wrote (bool true) while Python and C# wrote (bool True), and each decoder understood only its own spelling, so a compact document written by one language decoded to the wrong boolean in another. Every language now writes the lowercase form and reads either spelling, so old documents keep working. Adds a regression suite in all four languages. --- csharp/src/Lino.Objects.Codec/ObjectCodec.cs | 4 +- .../CompactInteropTests.cs | 39 +++++++++++++++++++ js/src/codec.js | 2 +- js/tests/test_compact_interop.test.js | 26 +++++++++++++ .../src/link_notation_objects_codec/codec.py | 4 +- python/tests/test_compact_interop.py | 25 ++++++++++++ rust/src/lib.rs | 2 +- rust/tests/compact_interop.rs | 27 +++++++++++++ 8 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 csharp/tests/Lino.Objects.Codec.Tests/CompactInteropTests.cs create mode 100644 js/tests/test_compact_interop.test.js create mode 100644 python/tests/test_compact_interop.py create mode 100644 rust/tests/compact_interop.rs diff --git a/csharp/src/Lino.Objects.Codec/ObjectCodec.cs b/csharp/src/Lino.Objects.Codec/ObjectCodec.cs index 4b15c9c..24c7aca 100644 --- a/csharp/src/Lino.Objects.Codec/ObjectCodec.cs +++ b/csharp/src/Lino.Objects.Codec/ObjectCodec.cs @@ -299,7 +299,7 @@ private Link EncodeValue(object? obj, HashSet? visited = null, i if (obj is bool boolVal) { - return MakeLink(TypeBool, boolVal ? "True" : "False"); + return MakeLink(TypeBool, boolVal ? "true" : "false"); } if (obj is int intVal) @@ -513,7 +513,7 @@ private Link EncodeValue(object? obj, HashSet? visited = null, i var boolValue = link.Values[1]; if (boolValue.Id is not null) { - return boolValue.Id == "True"; + return string.Equals(boolValue.Id, "true", StringComparison.OrdinalIgnoreCase); } } return false; diff --git a/csharp/tests/Lino.Objects.Codec.Tests/CompactInteropTests.cs b/csharp/tests/Lino.Objects.Codec.Tests/CompactInteropTests.cs new file mode 100644 index 0000000..bcfd58f --- /dev/null +++ b/csharp/tests/Lino.Objects.Codec.Tests/CompactInteropTests.cs @@ -0,0 +1,39 @@ +// The compact format must be readable across languages. +// +// Booleans used to be written differently per language: JavaScript and Rust +// wrote (bool true) while Python and C# wrote (bool True), and each decoder only +// understood its own spelling, so a document written by one language decoded to +// the wrong value in another. Every language now writes the lowercase form and +// reads either spelling. + +using Xunit; +using Lino.Objects.Codec; + +namespace Lino.Objects.Codec.Tests; + +/// +/// Verifies the compact format reads booleans written by any language. +/// +public class CompactInteropTests +{ + [Fact] + public void BooleansAreWrittenLowercase() + { + Assert.Equal("(bool true)", Codec.EncodeCompact(true)); + Assert.Equal("(bool false)", Codec.EncodeCompact(false)); + } + + [Fact] + public void LowercaseBooleansDecode() + { + Assert.Equal(true, Codec.DecodeCompact("(bool true)")); + Assert.Equal(false, Codec.DecodeCompact("(bool false)")); + } + + [Fact] + public void CapitalizedBooleansFromOlderDocumentsStillDecode() + { + Assert.Equal(true, Codec.DecodeCompact("(bool True)")); + Assert.Equal(false, Codec.DecodeCompact("(bool False)")); + } +} diff --git a/js/src/codec.js b/js/src/codec.js index e325b60..11a6b36 100644 --- a/js/src/codec.js +++ b/js/src/codec.js @@ -405,7 +405,7 @@ export class ObjectCodec { if (link.values.length > 1) { const boolValue = link.values[1]; if (boolValue && boolValue.id) { - return boolValue.id === 'true'; + return boolValue.id.toLowerCase() === 'true'; } } return false; diff --git a/js/tests/test_compact_interop.test.js b/js/tests/test_compact_interop.test.js new file mode 100644 index 0000000..9f1dfd1 --- /dev/null +++ b/js/tests/test_compact_interop.test.js @@ -0,0 +1,26 @@ +// The compact format must be readable across languages. +// +// Booleans used to be written differently per language: JavaScript and Rust +// wrote `(bool true)` while Python and C# wrote `(bool True)`, and each decoder +// only understood its own spelling, so a document written by one language +// decoded to the wrong value in another. Every language now writes the +// lowercase form and reads either spelling. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { encodeCompact, decodeCompact } from '../src/index.js'; + +test('booleans are written lowercase', () => { + assert.strictEqual(encodeCompact({ obj: true }), '(bool true)'); + assert.strictEqual(encodeCompact({ obj: false }), '(bool false)'); +}); + +test('lowercase booleans decode', () => { + assert.strictEqual(decodeCompact({ notation: '(bool true)' }), true); + assert.strictEqual(decodeCompact({ notation: '(bool false)' }), false); +}); + +test('capitalized booleans from older documents still decode', () => { + assert.strictEqual(decodeCompact({ notation: '(bool True)' }), true); + assert.strictEqual(decodeCompact({ notation: '(bool False)' }), false); +}); diff --git a/python/src/link_notation_objects_codec/codec.py b/python/src/link_notation_objects_codec/codec.py index 3133893..9e76fb1 100644 --- a/python/src/link_notation_objects_codec/codec.py +++ b/python/src/link_notation_objects_codec/codec.py @@ -355,7 +355,7 @@ def _encode_value(self, obj: Any, visited: set[int] | None = None, depth: int = elif isinstance(obj, bool): # Must check bool before int because bool is a subclass of int - return self._make_link(self.TYPE_BOOL, str(obj)) + return self._make_link(self.TYPE_BOOL, "true" if obj else "false") elif isinstance(obj, int): return self._make_link(self.TYPE_INT, str(obj)) @@ -489,7 +489,7 @@ def _decode_link(self, link: Link) -> Any: if len(link.values) > 1: bool_value = link.values[1] if hasattr(bool_value, "id"): - return bool_value.id == "True" + return bool_value.id.lower() == "true" return False elif type_marker == self.TYPE_INT: diff --git a/python/tests/test_compact_interop.py b/python/tests/test_compact_interop.py new file mode 100644 index 0000000..95e4093 --- /dev/null +++ b/python/tests/test_compact_interop.py @@ -0,0 +1,25 @@ +"""The compact format must be readable across languages. + +Booleans used to be written differently per language: JavaScript and Rust +wrote ``(bool true)`` while Python and C# wrote ``(bool True)``, and each +decoder only understood its own spelling, so a document written by one +language decoded to the wrong value in another. Every language now writes the +lowercase form and reads either spelling. +""" + +from link_notation_objects_codec import decode_compact, encode_compact + + +def test_booleans_are_written_lowercase() -> None: + assert encode_compact(True) == "(bool true)" + assert encode_compact(False) == "(bool false)" + + +def test_lowercase_booleans_decode() -> None: + assert decode_compact("(bool true)") is True + assert decode_compact("(bool false)") is False + + +def test_capitalized_booleans_from_older_documents_still_decode() -> None: + assert decode_compact("(bool True)") is True + assert decode_compact("(bool False)") is False diff --git a/rust/src/lib.rs b/rust/src/lib.rs index f7d64a9..120bfdb 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -805,7 +805,7 @@ impl ObjectCodec { type_ids::BOOL => { if values.len() > 1 { if let LiNo::Ref(val) = &values[1] { - return Ok(LinoValue::Bool(val == "true")); + return Ok(LinoValue::Bool(val.eq_ignore_ascii_case("true"))); } } Ok(LinoValue::Bool(false)) diff --git a/rust/tests/compact_interop.rs b/rust/tests/compact_interop.rs new file mode 100644 index 0000000..d18006b --- /dev/null +++ b/rust/tests/compact_interop.rs @@ -0,0 +1,27 @@ +//! The compact format must be readable across languages. +//! +//! Booleans used to be written differently per language: JavaScript and Rust +//! wrote `(bool true)` while Python and C# wrote `(bool True)`, and each decoder +//! only understood its own spelling, so a document written by one language +//! decoded to the wrong value in another. Every language now writes the +//! lowercase form and reads either spelling. + +use lino_objects_codec::{decode, encode_compact, LinoValue}; + +#[test] +fn booleans_are_written_lowercase() { + assert_eq!(encode_compact(&LinoValue::Bool(true)), "(bool true)"); + assert_eq!(encode_compact(&LinoValue::Bool(false)), "(bool false)"); +} + +#[test] +fn lowercase_booleans_decode() { + assert_eq!(decode("(bool true)"), Ok(LinoValue::Bool(true))); + assert_eq!(decode("(bool false)"), Ok(LinoValue::Bool(false))); +} + +#[test] +fn capitalized_booleans_from_older_documents_still_decode() { + assert_eq!(decode("(bool True)"), Ok(LinoValue::Bool(true))); + assert_eq!(decode("(bool False)"), Ok(LinoValue::Bool(false))); +} From 02f191b83500844083cd2eb32b3c330d8485e6e5 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:03:45 +0000 Subject: [PATCH 07/12] ci: add a cross-language parity gate that runs on every pull request PR #38 changed only the Rust codec and no other language's CI ran, because each workflow filters itself by paths:. This adds .github/workflows/parity.yml (no paths: filter) and scripts/check-language-parity.mjs, which fails a pull request when one language's src/ changed without the others. An intentional single-language change opts out with [skip-parity] in the PR title or body. Covered by scripts/check-language-parity.test.mjs (7 cases). --- .github/workflows/parity.yml | 43 +++++++++ scripts/check-language-parity.mjs | 128 +++++++++++++++++++++++++ scripts/check-language-parity.test.mjs | 74 ++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 .github/workflows/parity.yml create mode 100644 scripts/check-language-parity.mjs create mode 100644 scripts/check-language-parity.test.mjs diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml new file mode 100644 index 0000000..5b5fcda --- /dev/null +++ b/.github/workflows/parity.yml @@ -0,0 +1,43 @@ +name: Cross-Language Parity + +# Runs on every pull request with no `paths:` filter, so it cannot be skipped by +# touching only one language's subtree. It enforces that the four +# implementations (JavaScript, Python, Rust, C#) are always changed together, so +# they never drift apart the way they did in PR #38 (issue #39). + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + parity: + name: Languages Change Together + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Check parity-gate helper's own tests + run: node --test scripts/check-language-parity.test.mjs + + - name: Enforce cross-language parity + env: + PARITY_BASE_REF: ${{ github.base_ref }} + # An intentional single-language change opts out by writing + # `[skip-parity]` in the pull request title or body. + PARITY_SKIP_REASON: >- + ${{ (contains(github.event.pull_request.title, '[skip-parity]') || + contains(github.event.pull_request.body, '[skip-parity]')) + && 'opted out with [skip-parity] in the pull request' || '' }} + run: node scripts/check-language-parity.mjs diff --git a/scripts/check-language-parity.mjs b/scripts/check-language-parity.mjs new file mode 100644 index 0000000..e2a39da --- /dev/null +++ b/scripts/check-language-parity.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node + +/** + * Enforce that the four language implementations move together. + * + * Issue #39 requires that "any changes in code for a single language without a + * change in all of them will fail the CI/CD on pull requests, so all languages + * are always updated at the same time." PR #38 changed only the Rust codec and + * no other language's CI even ran, because every workflow filters itself by + * `paths:`. This check runs on every pull request with no such filter. + * + * The rule: if the library source of any language changed, the library source + * of every language must change too. A pull request that touches only shared + * files (docs, fixtures, root config) is unaffected. + * + * An intentional single-language change can opt out by putting the marker + * `[skip-parity]` in the pull request title or body, which the workflow passes + * in through the `PARITY_SKIP_REASON` environment variable. + */ + +import { execSync } from "child_process"; + +/** The languages that must stay in lock-step, and the paths that count as their library source. */ +export const LANGUAGES = [ + { id: "js", label: "JavaScript", sources: ["js/src/"] }, + { id: "python", label: "Python", sources: ["python/src/"] }, + { id: "rust", label: "Rust", sources: ["rust/src/"] }, + { id: "csharp", label: "C#", sources: ["csharp/src/"] }, +]; + +/** + * Decide which languages a set of changed files touches, and whether the change + * is balanced across all of them. + * + * @param {string[]} changedFiles - repository-relative paths that changed + * @returns {{changed: string[], missing: string[], balanced: boolean}} + */ +export function analyzeParity(changedFiles) { + const normalized = changedFiles.map((file) => + file.replace(/\\/g, "/").replace(/^\.\//, ""), + ); + const changed = LANGUAGES.filter((language) => + language.sources.some((source) => + normalized.some((file) => file.startsWith(source)), + ), + ).map((language) => language.id); + const missing = LANGUAGES.filter( + (language) => !changed.includes(language.id), + ).map((language) => language.id); + // Balanced when no language's source changed, or when every one did. + const balanced = changed.length === 0 || changed.length === LANGUAGES.length; + return { changed, missing, balanced }; +} + +/** + * List the files that differ between the merge base and the current HEAD. + * + * @param {string} baseRef - the branch the pull request targets, e.g. `main` + * @returns {string[]} + */ +export function changedFilesAgainst(baseRef) { + const range = baseRef ? `origin/${baseRef}...HEAD` : "HEAD~1...HEAD"; + const output = execSync(`git diff --name-only ${range}`, { + encoding: "utf-8", + }); + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function main() { + const skipReason = (process.env.PARITY_SKIP_REASON || "").trim(); + if (skipReason) { + console.log(`Language parity check skipped: ${skipReason}`); + return; + } + + const baseRef = + process.env.PARITY_BASE_REF || process.env.GITHUB_BASE_REF || ""; + let files; + try { + files = changedFilesAgainst(baseRef); + } catch (error) { + console.error(`Could not compute changed files: ${error.message}`); + process.exit(2); + } + + const { changed, missing, balanced } = analyzeParity(files); + if (balanced) { + if (changed.length === 0) { + console.log( + "Language parity: no language source changed; nothing to enforce.", + ); + } else { + console.log("Language parity: every language was updated together."); + } + return; + } + + const changedLabels = LANGUAGES.filter((l) => changed.includes(l.id)).map( + (l) => l.label, + ); + const missingLabels = LANGUAGES.filter((l) => missing.includes(l.id)).map( + (l) => l.label, + ); + console.error("Language parity check failed."); + console.error(` Changed: ${changedLabels.join(", ")}`); + console.error(` Missing a matching change: ${missingLabels.join(", ")}`); + console.error(""); + console.error( + "This repository keeps its four implementations byte-for-byte compatible, so a", + ); + console.error( + "change to one language must be mirrored in the others in the same pull request.", + ); + console.error( + "If this change is intentionally single-language, add `[skip-parity]` to the", + ); + console.error("pull request title or body."); + process.exit(1); +} + +// Run only when invoked directly, not when imported by the test. +const invokedPath = process.argv[1] ? process.argv[1].replace(/\\/g, "/") : ""; +if (invokedPath.endsWith("check-language-parity.mjs")) { + main(); +} diff --git a/scripts/check-language-parity.test.mjs b/scripts/check-language-parity.test.mjs new file mode 100644 index 0000000..c956639 --- /dev/null +++ b/scripts/check-language-parity.test.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +/** Tests for the cross-language parity gate. */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { analyzeParity, LANGUAGES } from "./check-language-parity.mjs"; + +test("a change to every language is balanced", () => { + const result = analyzeParity([ + "js/src/codec.js", + "python/src/link_notation_objects_codec/codec.py", + "rust/src/lib.rs", + "csharp/src/Lino.Objects.Codec/ObjectCodec.cs", + ]); + assert.deepEqual(result.changed.sort(), ["csharp", "js", "python", "rust"]); + assert.deepEqual(result.missing, []); + assert.equal(result.balanced, true); +}); + +test("a change to only one language is not balanced", () => { + const result = analyzeParity(["rust/src/lib.rs"]); + assert.deepEqual(result.changed, ["rust"]); + assert.deepEqual(result.missing.sort(), ["csharp", "js", "python"]); + assert.equal(result.balanced, false); +}); + +test("a change to three of four languages is not balanced", () => { + const result = analyzeParity([ + "js/src/codec.js", + "python/src/link_notation_objects_codec/codec.py", + "rust/src/lib.rs", + ]); + assert.deepEqual(result.missing, ["csharp"]); + assert.equal(result.balanced, false); +}); + +test("shared-only changes are balanced (nothing to enforce)", () => { + const result = analyzeParity([ + "README.md", + "fixtures/readable-format/cases.json", + ".github/workflows/parity.yml", + "docs/case-studies/issue-39/README.md", + ]); + assert.deepEqual(result.changed, []); + assert.equal(result.balanced, true); +}); + +test("tests and examples do not count as library source", () => { + const result = analyzeParity([ + "rust/tests/readable_format.rs", + "js/examples/basic_usage.js", + "python/tests/test_format.py", + ]); + assert.deepEqual(result.changed, []); + assert.equal(result.balanced, true); +}); + +test("backslash and ./ prefixed paths are normalized", () => { + const result = analyzeParity([ + "./js/src/codec.js", + "python\\src\\link_notation_objects_codec\\codec.py", + ]); + assert.deepEqual(result.changed.sort(), ["js", "python"]); +}); + +test("every declared language has a source path", () => { + for (const language of LANGUAGES) { + assert.ok( + language.sources.length > 0, + `${language.id} needs at least one source path`, + ); + } +}); From 2accdceb050f2cfa7acd373ff11624d64d534698 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:12:37 +0000 Subject: [PATCH 08/12] docs: document readable-by-default format, compact identity, and tracing across all READMEs --- README.md | 111 +++++++++++++++++++++++------------- csharp/README.md | 128 ++++++++++++++++++++++++++++------------- js/README.md | 124 ++++++++++++++++++++++++++-------------- python/README.md | 144 +++++++++++++++++++++++++++++++++-------------- rust/README.md | 25 +++++++- 5 files changed, 365 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index 24908d2..4ba2830 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ All implementations share the same design philosophy and provide feature parity. - **Rust**: `LinoValue` enum with `Null`, `Bool`, `Int`, `Float`, `String`, `Array`, `Object` - **C#**: `null`, `bool`, `int`, `long`, `float`, `double`, `string`, `List`, `Dictionary` - Special float/number values: `NaN`, `Infinity`, `-Infinity` -- **Circular References**: Automatically detect and preserve circular references -- **Object Identity**: Maintain object identity for shared references -- **UTF-8 Support**: Full Unicode string support using base64 encoding -- **Readable by Default (Rust)**: `encode()` writes indented, plain-text Links Notation; the previous single-line base64 form stays available as `encode_compact()` +- **Readable by Default**: In every language `encode()` writes indented, plain-text Links Notation; the previous single-line base64 form stays available as `encode_compact()` (alias `encode_obfuscated()`) +- **Object Identity**: Shared references and circular references are preserved by the compact format via object ids; the readable format is a plain tree and raises a circular-reference error instead +- **Full Unicode**: Strings are written as text; only a value that cannot be written as text (one holding control characters) is base64-encoded, and it is marked individually as `(base64 "…")` +- **Opt-in Tracing**: Set `LINO_CODEC_DEBUG=1` to trace encoding and decoding, the same way in every language - **Simple API**: Easy-to-use `encode()` and `decode()` functions - **JSON/Lino Conversion**: Convert between JSON and Links Notation (JavaScript) - **Reference Escaping**: Properly escape strings for Links Notation format (JavaScript) @@ -74,13 +74,13 @@ npm install lino-objects-codec ``` ```javascript -import { formatIndented, parseIndented } from "lino-objects-codec"; +import { encode, decode } from "lino-objects-codec"; -// Readable indented Links Notation for repository data +// `encode` produces readable, indented Links Notation by default const data = { name: "Alice", age: 30, active: true }; -const text = formatIndented({ id: "obj_root", obj: data }); -const { obj } = parseIndented({ text }); -console.log(JSON.stringify(obj) === JSON.stringify(data)); // true +const encoded = encode({ obj: data }); +const decoded = decode({ notation: encoded }); +console.log(JSON.stringify(decoded) === JSON.stringify(data)); // true ``` ### Rust @@ -116,7 +116,7 @@ assert_eq!(decoded, data); ``` The single-line base64 form is still available as `encode_compact()` (alias -`encode_obfuscated()`), and `decode()` accepts both forms. +`encode_obfuscated()`) in every language, and `decode()` accepts both forms. ### C# @@ -180,39 +180,44 @@ All implementations support the same features with language-appropriate syntax: ### Circular References +Object identity -- shared nodes and cycles -- is a property of the **compact** +format, which names shared nodes with `obj_N` ids. The readable format is a plain +tree with nowhere to put those ids, so `encode()` raises a circular-reference +error on a cycle; use `encode_compact()` (the compact form) when you need +identity preserved. + **Python:** ```python -from link_notation_objects_codec import encode, decode +from link_notation_objects_codec import decode, encode_compact -# Self-referencing list +# Self-referencing list -- preserved by the compact format lst = [1, 2, 3] lst.append(lst) -decoded = decode(encode(lst)) +decoded = decode(encode_compact(lst)) assert decoded[3] is decoded # Reference preserved ``` **JavaScript:** ```javascript -import { encode, decode } from "lino-objects-codec"; +import { encodeCompact, decode } from "lino-objects-codec"; -// Self-referencing array +// Self-referencing array -- preserved by the compact format const arr = [1, 2, 3]; arr.push(arr); -const decoded = decode(encode(arr)); +const decoded = decode({ notation: encodeCompact({ obj: arr }) }); console.log(decoded[3] === decoded); // true - Reference preserved ``` **Rust:** ```rust -use lino_objects_codec::{encode, decode, LinoValue}; +use lino_objects_codec::{encode_compact, decode, LinoValue}; -// Self-referencing structures are handled via object IDs +// Self-referencing structures are handled via object ids in the compact form let data = LinoValue::array([LinoValue::Int(1), LinoValue::Int(2)]); -let encoded = encode(&data); -let decoded = decode(&encoded).unwrap(); +let decoded = decode(&encode_compact(&data)).unwrap(); // Reference semantics preserved through encoding/decoding ``` @@ -221,10 +226,10 @@ let decoded = decode(&encoded).unwrap(); ```csharp using Lino.Objects.Codec; -// Self-referencing list +// Self-referencing list -- preserved by the compact format var lst = new List(); lst.Add(lst); -var decoded = Codec.Decode(Codec.Encode(lst)) as List; +var decoded = Codec.Decode(Codec.EncodeCompact(lst)) as List; Console.WriteLine(ReferenceEquals(decoded, decoded?[0])); // True - Reference preserved ``` @@ -367,25 +372,51 @@ var (id, parsedObj) = Format.ParseIndented(formatted); The library uses the [links-notation](https://github.com/link-foundation/links-notation) format as the serialization target. Each object is encoded as a Link with type information: -- Basic types are encoded with type markers: `(int 42)`, `(str aGVsbG8=)`, `(bool True)` +### Readable format (the default) + +In every language `encode()` writes one `( )` construct for both objects and +arrays, at every level including the root. Lines of the form `key value` make an +object, bare-value lines make an array: + +```lino +( + name "Alice" + age 30 + active true +) +``` + +- Strings are double-quoted and written as text; numbers, `true`, `false` and + `null` are bare, so types survive a round trip +- `NaN`, `Infinity` and `-Infinity` are written as such +- An empty array is `()`; an empty object is `(` + newline + `)` +- Only a value that cannot be written as text (one containing control characters) + is base64-encoded, and it is marked individually as `(base64 "bGluZTEKbGluZTI=")` +- The four languages produce byte-identical output, checked by the shared + fixtures in [`fixtures/readable-format/cases.json`](fixtures/readable-format/cases.json) + +### Compact format (`encode_compact`) + +The previous single-line form, kept for compatibility and for the object graphs +the readable tree cannot express (shared and circular references): + +- Basic types are encoded with type markers: `(int 42)`, `(str aGVsbG8=)`, `(bool true)` - Strings are base64-encoded to handle special characters and newlines -- **Rust exception**: `encode()` defaults to the readable indented form described in - [rust/README.md](rust/README.md), where strings are quoted rather than encoded and - only values containing control characters are marked as `(base64 "...")`; the form - above is what `encode_compact()` produces -- Collections with self-references use built-in links notation self-reference syntax: - - **Format**: `(obj_id: type content...)` - - **Python example**: `(obj_0: dict ((str c2VsZg==) obj_0))` for `{"self": obj}` - - **JavaScript example**: `(obj_0: array (int 1) (int 2) obj_0)` for self-referencing array -- Simple collections without shared references use format: `(list item1 item2 ...)` or `(dict (key val) ...)` -- Circular references use direct object ID references: `obj_0` (without the `ref` keyword) - -This approach allows for: - -- Universal representation of object graphs -- Preservation of object identity -- Natural handling of circular references using built-in links notation syntax -- Cross-language compatibility +- Collections with self-references use `(obj_id: type content...)`, e.g. + `(obj_0: dict ((str c2VsZg==) obj_0))` for `{"self": obj}` +- Circular references use direct object id references: `obj_0` (without a `ref` keyword) + +`decode()` detects which of the two forms it is given, so previously written +files keep decoding, and every language reads the compact documents the others +write. + +## Debugging + +Tracing is off by default and can be turned on in any language by setting the +`LINO_CODEC_DEBUG` environment variable to a truthy value (`1`, `true`, `yes` or +`on`), or from code (`set_debug_enabled` / `setDebugEnabled` / +`CodecDebug.SetEnabled`). Trace lines go to standard error, prefixed with +`[lino-codec]`. ## Development diff --git a/csharp/README.md b/csharp/README.md index 41297c7..c4aae7a 100644 --- a/csharp/README.md +++ b/csharp/README.md @@ -14,9 +14,10 @@ A C# library for working with Links Notation format. This library provides unive - Basic types: `null`, `bool`, `int`, `long`, `float`, `double`, `string` - Collections: `List`, `Dictionary` - Special float values: `NaN`, `Infinity`, `-Infinity` -- **Circular References**: Automatically detect and preserve circular references -- **Object Identity**: Maintain object identity for shared references -- **UTF-8 Support**: Full Unicode string support using base64 encoding +- **Readable by Default**: `Codec.Encode()` writes plain, indented text that can be read and reviewed +- **Object Identity**: Shared references and circular references are preserved by the compact format (`Codec.EncodeCompact`) via object ids +- **Full Unicode**: Strings are written as text; only a value that cannot be written as text (one holding control characters) is base64-encoded, and it is marked individually as `(base64 "…")` +- **Opt-in Tracing**: Set `LINO_CODEC_DEBUG=1` to trace encoding and decoding, the same way in every language - **Simple API**: Easy-to-use `Codec.Encode()` and `Codec.Decode()` functions - **Thread Safe**: Each operation uses a fresh codec instance @@ -53,7 +54,12 @@ var encoded = Codec.Encode(new Dictionary { "active", true } }); Console.WriteLine(encoded); -// Output: (dict ((str bmFtZQ==) (str QWxpY2U=)) ((str YWdl) (int 30)) ((str YWN0aXZl) (bool True))) +// Output: +// ( +// name "Alice" +// age 30 +// active true +// ) // Decode back to C# object var decoded = Codec.Decode(encoded) as Dictionary; @@ -141,68 +147,110 @@ var complexData = new Dictionary decoded = Codec.Decode(Codec.Encode(complexData)); ``` +## Output Formats + +| Method | Output | +| --- | --- | +| `Codec.Encode(obj)` | Readable, indented Links Notation (the default) | +| `Codec.Encode(obj, "\t")` | Same, with a custom indentation string | +| `Codec.EncodeCompact(obj)` | The previous single-line, base64 form | +| `Codec.EncodeObfuscated(obj)` | Alias of `Codec.EncodeCompact` | + +`Codec.Decode()` accepts every one of them, so files written by older versions +keep working and are rewritten in the readable form the next time they are saved. + ### Circular References -The library automatically handles circular references and shared objects: +Object identity -- shared nodes and cycles -- is a property of the **compact** +format, which names shared nodes with `obj_N` ids. The readable format is a plain +tree with nowhere to put those ids, so `Codec.Encode` throws +`CircularReferenceException` on a cycle. Use `Codec.EncodeCompact` when you need +identity preserved: ```csharp using Lino.Objects.Codec; -// Self-referencing list +// Self-referencing list -- preserved by the compact format var selfRef = new List(); selfRef.Add(selfRef); // Circular reference -var encoded = Codec.Encode(selfRef); +var encoded = Codec.EncodeCompact(selfRef); // Output: (obj_0: list obj_0) var decoded = Codec.Decode(encoded) as List; Console.WriteLine(ReferenceEquals(decoded, decoded?[0])); // True - Reference preserved -// Self-referencing dictionary -var selfRefDict = new Dictionary(); -selfRefDict["self"] = selfRefDict; // Circular reference -encoded = Codec.Encode(selfRefDict); -// Output: (obj_0: dict ((str c2VsZg==) obj_0)) -var decodedDict = Codec.Decode(encoded) as Dictionary; -Console.WriteLine(ReferenceEquals(decodedDict, decodedDict?["self"])); // True - -// Shared references +// Shared references -- the same object is restored once var shared = new Dictionary { { "shared", "data" } }; var container = new Dictionary { { "first", shared }, { "second", shared } }; -encoded = Codec.Encode(container); -var decodedContainer = Codec.Decode(encoded) as Dictionary; -// Both references point to the same object +var decodedContainer = Codec.Decode(Codec.EncodeCompact(container)) as Dictionary; Console.WriteLine(ReferenceEquals(decodedContainer?["first"], decodedContainer?["second"])); // True -// Complex circular structure (tree with back-references) -var root = new Dictionary { { "name", "root" }, { "children", new List() } }; -var child = new Dictionary { { "name", "child" }, { "parent", root } }; -((List)root["children"]!).Add(child); -encoded = Codec.Encode(root); -var decodedRoot = Codec.Decode(encoded) as Dictionary; -var decodedChild = ((List)decodedRoot?["children"]!)[0] as Dictionary; -Console.WriteLine(ReferenceEquals(decodedRoot, decodedChild?["parent"])); // True +// The readable format rejects a cycle rather than losing the identity +try +{ + Codec.Encode(selfRef); +} +catch (CircularReferenceException error) +{ + Console.WriteLine(error.GetType().Name); // CircularReferenceException +} ``` ## How It Works -The library uses the [links-notation](https://github.com/link-foundation/links-notation) format as the serialization target. Each C# object is encoded as a Link with type information: +The library uses the [links-notation](https://github.com/link-foundation/links-notation) format as the serialization target. -- Basic types are encoded with type markers: `(int 42)`, `(str SGVsbG8=)`, `(bool True)` +### Readable format (the default) + +`Codec.Encode` writes one `( )` construct for both dictionaries and lists, at +every level including the root. Lines of the form `key value` make a dictionary, +bare-value lines make a list: + +- Strings are double-quoted and written as text: `name "Alice"` +- Numbers, `true`, `false` and `null` are bare, so types survive a round trip +- `NaN`, `Infinity` and `-Infinity` are written as such +- An empty list is `()`; an empty dictionary is `(` + newline + `)` +- A value that cannot be written as text (one containing control characters) is + base64-encoded on its own and marked as `(base64 "bGluZTEKbGluZTI=")`; + everything around it stays readable + +### Compact format (`Codec.EncodeCompact`) + +The previous single-line form, kept for compatibility and for the object graphs +the readable tree cannot express (shared and circular references): + +- Basic types carry a type marker: `(int 42)`, `(str SGVsbG8=)`, `(bool true)` - Strings are base64-encoded to handle special characters and newlines -- Collections with self-references use built-in links notation self-reference syntax: - - **Format**: `(obj_id: type content...)` - - **Example**: `(obj_0: dict ((str c2VsZg==) obj_0))` for `{"self": obj}` -- Simple collections without shared references use format: `(list item1 item2 ...)` or `(dict (key val) ...)` -- Circular references use direct object ID references: `obj_0` (without the `ref` keyword) - -This approach allows for: -- Universal representation of object graphs -- Preservation of object identity -- Natural handling of circular references using built-in links notation syntax -- Cross-language compatibility with Python and JavaScript implementations +- Collections with self-references use `(obj_id: type content...)`, e.g. + `(obj_0: dict ((str c2VsZg==) obj_0))` for `{"self": obj}` +- Circular references use direct object ID references: `obj_0` (without a `ref` keyword) + +`Codec.Decode` detects which of the two forms it is given, so previously written +files keep decoding, and every language reads the compact documents the others +write. + +## Debugging + +Tracing is off by default. Turn it on to see what the codec does, either from +the environment or from code: + +```bash +LINO_CODEC_DEBUG=1 dotnet run # 1, true, yes or on +``` + +```csharp +using Lino.Objects.Codec; + +CodecDebug.SetEnabled(true); // force on +CodecDebug.SetEnabled(null); // follow LINO_CODEC_DEBUG again +``` + +Trace lines are written to standard error, prefixed with `[lino-codec]`. The +same switch and the same `LINO_CODEC_DEBUG` variable exist in the JavaScript, +Python and Rust implementations. ## API Reference diff --git a/js/README.md b/js/README.md index 9b4e07f..62a653d 100644 --- a/js/README.md +++ b/js/README.md @@ -26,9 +26,10 @@ These tools enable easy implementation of higher-level features like: - Basic types: `null`, `undefined`, `boolean`, `number`, `string` - Collections: `Array`, `Object` - Special number values: `NaN`, `Infinity`, `-Infinity` -- **Circular References**: Automatically detect and preserve circular references in the typed codec -- **Object Identity**: Maintain object identity for shared references in the typed codec -- **UTF-8 Support**: Full Unicode string support in the typed codec using base64 encoding +- **Readable by Default**: `encode({ obj })` writes plain, indented text that can be read and reviewed +- **Object Identity**: Shared references and circular references are preserved by the compact format (`encodeCompact`) via object ids +- **Full Unicode**: Strings are written as text; only a value that cannot be written as text (one holding control characters) is base64-encoded, and it is marked individually as `(base64 "…")` +- **Opt-in Tracing**: Set `LINO_CODEC_DEBUG=1` to trace encoding and decoding, the same way in every language - **Compact JSON/Lino Conversion**: Convert between JSON and compact Links Notation with `jsonToLino({ json })` and `linoToJson({ lino })` - **Reference Escaping**: Properly escape strings for Links Notation format with `escapeReference({ value })` - **Fuzzy Matching**: Find similar strings with Levenshtein distance and keyword similarity @@ -87,21 +88,37 @@ console.log(parsed.obj.items[1] === 1); // Output: true ``` -Use the typed codec when you need exact JavaScript type preservation, circular references, or shared object identity: +`encode({ obj })` writes the readable format. Use `encodeCompact({ obj })` when you +need exact JavaScript type preservation, circular references, or shared object +identity -- the compact format names shared nodes with `obj_N` ids, which the +readable tree has nowhere to put: ```javascript -import { encode, decode } from 'lino-objects-codec'; +import { encodeCompact, decode } from 'lino-objects-codec'; const obj = { name: 'root' }; obj.self = obj; -const encoded = encode({ obj }); +const encoded = encodeCompact({ obj }); const decoded = decode({ notation: encoded }); console.log(decoded.self === decoded); // Output: true ``` +## Output Formats + +| Function | Output | +| ------------------------------- | ----------------------------------------------- | +| `encode({ obj })` | Readable, indented Links Notation (the default) | +| `encode({ obj, indent: '\t' })` | Same, with a custom indentation string | +| `encodeCompact({ obj })` | The previous single-line, base64 form | +| `encodeObfuscated({ obj })` | Alias of `encodeCompact` | + +`decode({ notation })` accepts every one of them, so files written by older +versions keep working and are rewritten in the readable form the next time they +are saved. + ## Usage Examples ### Readable Indented Data @@ -192,40 +209,32 @@ console.log(JSON.stringify(decode({ notation: encode({ obj: complexData } }))) = ### Circular References -The library automatically handles circular references and shared objects: +Object identity -- shared nodes and cycles -- is a property of the **compact** +format, which names shared nodes with `obj_N` ids. The readable format is a plain +tree with nowhere to put those ids, so `encode` throws `CircularReferenceError` +on a cycle. Use `encodeCompact` when you need identity preserved: ```javascript -import { encode, decode } from 'lino-objects-codec'; +import { encode, encodeCompact, decode } from 'lino-objects-codec'; -// Self-referencing array +// Self-referencing array -- preserved by the compact format const arr = [1, 2, 3]; arr.push(arr); // Circular reference -const encoded = encode({ obj: arr }); -const decoded = decode({ notation: encoded }); +const decoded = decode({ notation: encodeCompact({ obj: arr }) }); console.log(decoded[3] === decoded); // true - Reference preserved -// Self-referencing object -const obj = { name: 'root' }; -obj.self = obj; // Circular reference -const encoded2 = encode({ obj: obj }); -const decoded2 = decode({ notation: encoded2 }); -console.log(decoded2.self === decoded2); // true - Reference preserved - -// Shared references +// Shared references -- the same object is restored once const shared = { shared: 'data' }; const container = { first: shared, second: shared }; -const encoded3 = encode({ obj: container }); -const decoded3 = decode({ notation: encoded3 }); -// Both references point to the same object +const decoded3 = decode({ notation: encodeCompact({ obj: container }) }); console.log(decoded3.first === decoded3.second); // true -// Complex circular structure (tree with back-references) -const root = { name: 'root', children: [] }; -const child = { name: 'child', parent: root }; -root.children.push(child); -const encoded4 = encode({ obj: root }); -const decoded4 = decode({ notation: encoded4 }); -console.log(decoded4.children[0].parent === decoded4); // true +// The readable format rejects a cycle rather than losing the identity +try { + encode({ obj: arr }); +} catch (error) { + console.log(error.name); // CircularReferenceError +} ``` ### JSON/Lino Conversion @@ -311,26 +320,55 @@ Readable indented mode emits a root definition and a definition for each nested - Empty arrays are written as `()` - Quoted references parse as strings; unquoted references parse dynamically as numbers, booleans, `null`, definition references, or strings -The typed codec uses explicit type information: +### Readable format (the default) + +`encode({ obj })` writes one `( )` construct for both objects and arrays, at +every level including the root. Lines of the form `key value` make an object, +bare-value lines make an array: + +- Strings are double-quoted and written as text: `name "Alice"` +- Numbers, `true`, `false` and `null` are bare, so types survive a round trip +- `NaN`, `Infinity` and `-Infinity` are written as such +- An empty array is `()`; an empty object is `(` + newline + `)` +- A value that cannot be written as text (one containing control characters) is + base64-encoded on its own and marked as `(base64 "bGluZTEKbGluZTI=")`; + everything around it stays readable + +### Compact format (`encodeCompact`) -- Basic types are encoded with type markers: `(int 42)`, `(str "hello")`, `(bool true)` +The previous single-line form, kept for compatibility and for the object graphs +the readable tree cannot express (shared and circular references): + +- Basic types carry a type marker: `(int 42)`, `(str aGVsbG8=)`, `(bool true)` - Strings are base64-encoded to handle special characters and newlines -- Shared / cyclic collections are defined inline with a self-reference id using - the built-in links-notation `(self-ref: first-ref second-ref ...)` form, e.g. - `(obj_0: array (int 1) (int 2) ...)` or `(obj_0: object (key val) ...)` -- Circular references use built-in links-notation references — the bare object - id link `obj_0` — instead of a dedicated keyword. For example, a self- - referencing object `{ self: obj }` encodes as - `(obj_0: object ((str c2VsZg==) obj_0))` (no `(ref obj_0)` marker). See +- Shared / cyclic collections are defined inline with a self-reference id, e.g. + `(obj_0: array (int 1) (int 2) ...)`; a self-referencing object `{ self: obj }` + encodes as `(obj_0: object ((str c2VsZg==) obj_0))`. See [issue #27](https://github.com/link-foundation/lino-objects-codec/issues/27) for the rationale. -This approach allows for: +`decode` detects which of the two forms it is given, so previously written files +keep decoding. + +## Debugging + +Tracing is off by default. Turn it on to see what the codec does, either from +the environment or from code: + +```bash +LINO_CODEC_DEBUG=1 node your_script.js # 1, true, yes or on +``` + +```javascript +import { setDebugEnabled } from 'lino-objects-codec'; + +setDebugEnabled(true); // force on +setDebugEnabled(null); // follow LINO_CODEC_DEBUG again +``` -- Universal representation of object graphs -- Preservation of object identity -- Natural handling of circular references -- Exact typed round-trips when readability is less important than preserving JavaScript semantics +Trace lines are written to standard error, prefixed with `[lino-codec]`. The +same switch and the same `LINO_CODEC_DEBUG` variable exist in the Python, Rust +and C# implementations. ## API Reference diff --git a/python/README.md b/python/README.md index 93cb2c2..f4646c3 100644 --- a/python/README.md +++ b/python/README.md @@ -9,14 +9,15 @@ A Python library to encode/decode objects to/from Links Notation format. This li ## Features +- **Readable by Default**: `encode()` writes plain, indented text that can be read and reviewed - **Universal Serialization**: Encode Python objects to Links Notation format - **Type Support**: Handle all common Python types: - Basic types: `None`, `bool`, `int`, `float`, `str` - Collections: `list`, `dict` - Special float values: `NaN`, `Infinity`, `-Infinity` -- **Circular References**: Automatically detect and preserve circular references -- **Object Identity**: Maintain object identity for shared references -- **UTF-8 Support**: Full Unicode string support using base64 encoding +- **Object Identity**: Shared references and circular references are preserved by the compact format via object ids +- **Full Unicode**: Strings are written as text; only a value that cannot be written as text (one holding control characters) is base64-encoded, and it is marked individually as `(base64 "…")` +- **Opt-in Tracing**: Set `LINO_CODEC_DEBUG=1` to trace encoding and decoding, the same way in every language - **Simple API**: Easy-to-use `encode()` and `decode()` functions ## Installation @@ -33,7 +34,12 @@ from link_notation_objects_codec import encode, decode # Encode basic types encoded = encode({"name": "Alice", "age": 30, "active": True}) print(encoded) -# Output: (dict obj_0 ((str bm5h...) (int 30)) ((str YWN0...) (bool True))) +# Output: +# ( +# name "Alice" +# age 30 +# active true +# ) # Decode back to Python object decoded = decode(encoded) @@ -108,65 +114,119 @@ complex_data = { assert decode(encode(complex_data)) == complex_data ``` +## Output Formats + +| Function | Output | +| --- | --- | +| `encode(obj)` | Readable, indented Links Notation (the default) | +| `encode(obj, indent="\t")` | Same, with a custom indentation string | +| `encode_compact(obj)` | The previous single-line, base64 form | +| `encode_obfuscated(obj)` | Alias of `encode_compact` | + +`decode()` accepts every one of them, so files written by older versions keep +working and are rewritten in the readable form the next time they are saved. + ### Circular References -The library automatically handles circular references and shared objects: +Object identity -- shared nodes and cycles -- is a property of the **compact** +format, which names shared nodes with `obj_N` ids. The readable format is a plain +tree with nowhere to put those ids, so `encode()` raises `CircularReferenceError` +on a cycle. Use `encode_compact()` when you need identity preserved: ```python -from link_notation_objects_codec import encode, decode - -# Self-referencing list +from link_notation_objects_codec import ( + CircularReferenceError, + decode, + encode, + encode_compact, +) + +# Self-referencing list -- preserved by the compact format lst = [1, 2, 3] lst.append(lst) # Circular reference -encoded = encode(lst) -decoded = decode(encoded) +decoded = decode(encode_compact(lst)) assert decoded[3] is decoded # Reference preserved -# Self-referencing dictionary -d = {"name": "root"} -d["self"] = d # Circular reference -encoded = encode(d) -decoded = decode(encoded) -assert decoded["self"] is decoded # Reference preserved - -# Shared references +# Shared references -- the same object is restored once shared = {"shared": "data"} container = {"first": shared, "second": shared} -encoded = encode(container) -decoded = decode(encoded) -# Both references point to the same object +decoded = decode(encode_compact(container)) assert decoded["first"] is decoded["second"] -# Complex circular structure (tree with back-references) -root = {"name": "root", "children": []} -child = {"name": "child", "parent": root} -root["children"].append(child) -encoded = encode(root) -decoded = decode(encoded) -assert decoded["children"][0]["parent"] is decoded +# The readable format rejects a cycle rather than losing the identity +try: + encode(lst) +except CircularReferenceError as error: + print(error) # Cannot write a circular reference in the readable format; use encode_compact ``` ## How It Works -The library uses the [links-notation](https://github.com/link-foundation/links-notation) format as the serialization target. Each Python object is encoded as a Link with type information: +The library uses the [links-notation](https://github.com/link-foundation/links-notation) format as the serialization target. + +### Readable format (the default) + +One `( )` construct carries both objects and arrays, at every level including +the root. Lines of the form `key value` make a dict, bare-value lines make a +list: + +```lino +( + type "RouterState" + server ( + host "127.0.0.1" + port 18878 + ) + models ( + "claude-haiku" + "claude-opus" + ) +) +``` + +- Strings are double-quoted and written as text: `name "Alice"` +- Numbers, `true`, `false` and `null` are bare, so types survive a round trip +- `NaN`, `Infinity` and `-Infinity` are written as such +- An empty list is `()`; an empty dict is `(` + newline + `)` +- A value that cannot be written as text (one containing control characters) is + base64-encoded on its own and marked as `(base64 "bGluZTEKbGluZTI=")`; + everything around it stays readable -- Basic types are encoded with type markers: `(int 42)`, `(str "hello")`, `(bool True)` +### Compact format (`encode_compact`) + +The previous single-line form, kept for compatibility and for the object graphs +the readable tree cannot express (shared and circular references): + +- Basic types carry a type marker: `(int 42)`, `(str aGVsbG8=)`, `(bool true)` - Strings are base64-encoded to handle special characters and newlines -- Shared / cyclic collections are defined inline with a self-reference id using - the built-in links-notation `(self-ref: first-ref second-ref ...)` form, e.g. - `(obj_0: list (int 1) (int 2) ...)` or `(obj_0: dict (key val) ...)` -- Circular references use built-in links-notation references — the bare object - id link `obj_0` — instead of a dedicated keyword. For example, a self- - referencing dict `{"self": obj}` encodes as - `(obj_0: dict ((str c2VsZg==) obj_0))` (no `(ref obj_0)` marker). See +- Shared / cyclic collections are defined inline with a self-reference id, e.g. + `(obj_0: list (int 1) (int 2) ...)`; a self-referencing dict `{"self": obj}` + encodes as `(obj_0: dict ((str c2VsZg==) obj_0))`. See [issue #27](https://github.com/link-foundation/lino-objects-codec/issues/27) for the rationale. -This approach allows for: -- Universal representation of object graphs -- Preservation of object identity -- Natural handling of circular references -- Human-readable (somewhat) output +`decode()` detects which of the two forms it is given, so previously written +files keep decoding. + +## Debugging + +Tracing is off by default. Turn it on to see what the codec does, either from +the environment or from code: + +```bash +LINO_CODEC_DEBUG=1 python your_script.py # 1, true, yes or on +``` + +```python +from link_notation_objects_codec import set_debug_enabled + +set_debug_enabled(True) # force on +set_debug_enabled(None) # follow LINO_CODEC_DEBUG again +``` + +Trace lines are written to standard error, prefixed with `[lino-codec]`. The +same switch and the same `LINO_CODEC_DEBUG` variable exist in the JavaScript, +Rust and C# implementations. ## API Reference diff --git a/rust/README.md b/rust/README.md index 8cf757a..82eae66 100644 --- a/rust/README.md +++ b/rust/README.md @@ -28,8 +28,9 @@ lino-objects-codec = "0.1" - `array` (Array) - `object` (Object) - **Special Float Values**: Full support for NaN, Infinity, -Infinity (which are not valid JSON) -- **Circular References**: Detect and preserve circular references via object IDs -- **Object Identity**: Maintain object identity for shared references +- **Circular References**: Preserved by the compact format (`encode_compact`) via object ids; the readable format is a plain tree and returns a `CircularReference` error instead +- **Object Identity**: Shared references are preserved by the compact format +- **Opt-in Tracing**: Set `LINO_CODEC_DEBUG=1` to trace encoding and decoding, the same way in every language - **Readable by Default**: `encode()` writes indented, plain-text Links Notation; keys and values stay legible and diffable - **UTF-8 Support**: Full Unicode string support written as text; only values that cannot be written as text (control characters) are base64-encoded, and each is marked individually - **Simple API**: Easy-to-use `encode()` and `decode()` functions @@ -335,6 +336,26 @@ uses object IDs: `decode()` detects which of the two forms it is given, so previously written files keep decoding. +## Debugging + +Tracing is off by default. Turn it on to see what the codec does, either from +the environment or from code: + +```bash +LINO_CODEC_DEBUG=1 cargo run --example basic_usage # 1, true, yes or on +``` + +```rust +use lino_objects_codec::debug; + +debug::set_debug_enabled(Some(true)); // force on +debug::set_debug_enabled(None); // follow LINO_CODEC_DEBUG again +``` + +Trace lines are written to standard error, prefixed with `[lino-codec]`. The +same switch and the same `LINO_CODEC_DEBUG` variable exist in the JavaScript, +Python and C# implementations. + ## Development ```bash From 84f6f964a23711e7dda928477ee664e45ef1e042 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:19:04 +0000 Subject: [PATCH 09/12] chore(release): add changelog fragments for issue #39 across all four languages --- ...260820_071844_issue_39_readable_default.md | 12 ++++++++++ ...260820_071844_issue_39_readable_default.md | 12 ++++++++++ ...260820_071844_issue_39_readable_default.md | 20 +++++++++++++++++ ...260820_071844_issue_39_readable_default.md | 22 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 csharp/.changeset/20260820_071844_issue_39_readable_default.md create mode 100644 js/.changeset/20260820_071844_issue_39_readable_default.md create mode 100644 python/changelog.d/20260820_071844_issue_39_readable_default.md create mode 100644 rust/changelog.d/20260820_071844_issue_39_readable_default.md diff --git a/csharp/.changeset/20260820_071844_issue_39_readable_default.md b/csharp/.changeset/20260820_071844_issue_39_readable_default.md new file mode 100644 index 0000000..3281896 --- /dev/null +++ b/csharp/.changeset/20260820_071844_issue_39_readable_default.md @@ -0,0 +1,12 @@ +--- +'Lino.Objects.Codec': minor +--- + +Make the readable indented Links Notation the default `Codec.Encode`/`Codec.Decode` +output, matching the Rust implementation, and add opt-in tracing via the +`LINO_CODEC_DEBUG` environment variable (or `CodecDebug.SetEnabled` from code). +Byte identical output across all four languages is now locked in by the shared +fixtures in `fixtures/readable-format/cases.json`. Also fixes cross-language +compact interop: booleans are written lowercase (`(bool true)`) and decoded +case-insensitively so documents written by any language decode in every other. +See issue #39: https://github.com/link-foundation/lino-objects-codec/issues/39 diff --git a/js/.changeset/20260820_071844_issue_39_readable_default.md b/js/.changeset/20260820_071844_issue_39_readable_default.md new file mode 100644 index 0000000..0e415c0 --- /dev/null +++ b/js/.changeset/20260820_071844_issue_39_readable_default.md @@ -0,0 +1,12 @@ +--- +'lino-objects-codec': minor +--- + +Make the readable indented Links Notation the default `encode`/`decode` output, +matching the Rust implementation, and add opt-in tracing via the +`LINO_CODEC_DEBUG` environment variable (or `setDebugEnabled` from code). Byte +identical output across all four languages is now locked in by the shared +fixtures in `fixtures/readable-format/cases.json`. Also fixes cross-language +compact interop: booleans are written lowercase (`(bool true)`) and decoded +case-insensitively so documents written by any language decode in every other. +See [issue #39](https://github.com/link-foundation/lino-objects-codec/issues/39). diff --git a/python/changelog.d/20260820_071844_issue_39_readable_default.md b/python/changelog.d/20260820_071844_issue_39_readable_default.md new file mode 100644 index 0000000..2224c9b --- /dev/null +++ b/python/changelog.d/20260820_071844_issue_39_readable_default.md @@ -0,0 +1,20 @@ +### Added + +- Opt-in tracing via the `LINO_CODEC_DEBUG` environment variable (`1`, `true`, + `yes`, `on`) or `set_debug_enabled` from code, matching the JavaScript, Rust + and C# implementations. See + [issue #39](https://github.com/link-foundation/lino-objects-codec/issues/39). + +### Changed + +- The readable indented Links Notation is now the default `encode`/`decode` + output, matching the Rust implementation. Byte identical output across all + four languages is locked in by the shared fixtures in + `fixtures/readable-format/cases.json`. Object identity and circular references + remain available through `encode_compact`. + +### Fixed + +- Cross-language compact interop: booleans are written lowercase (`(bool true)`) + and decoded case-insensitively, so a compact document written by any language + decodes correctly in every other. diff --git a/rust/changelog.d/20260820_071844_issue_39_readable_default.md b/rust/changelog.d/20260820_071844_issue_39_readable_default.md new file mode 100644 index 0000000..dc83481 --- /dev/null +++ b/rust/changelog.d/20260820_071844_issue_39_readable_default.md @@ -0,0 +1,22 @@ +--- +bump: minor +--- + +### Added + +- Opt-in tracing via the `LINO_CODEC_DEBUG` environment variable (`1`, `true`, + `yes`, `on`) or `debug::set_debug_enabled` from code, matching the JavaScript, + Python and C# implementations. See + [issue #39](https://github.com/link-foundation/lino-objects-codec/issues/39). + +### Changed + +- The readable indented output is now verified byte-for-byte against the other + three languages through the shared fixtures in + `fixtures/readable-format/cases.json`. + +### Fixed + +- Cross-language compact interop: booleans in the compact form are now decoded + case-insensitively, so `(bool True)` written by the Python or C# + implementations decodes correctly in Rust. From 44feb5b9370b6f006293606ba5b2c5f430459b80 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:22:32 +0000 Subject: [PATCH 10/12] fix(rust-ci): make changelog-fragment check use --relative paths so it works in the monorepo The check matched repo-root-relative git paths (rust/src/...) against subdirectory-relative patterns (^src/, ^scripts/, changelog.d/). In the monorepo this silently disabled fragment enforcement for real rust/src changes and falsely tripped on unrelated repo-root scripts/ changes. Adding --relative reports paths relative to the CI working-directory (rust/), so the existing patterns match correctly. Refs #39. --- rust/scripts/check-changelog-fragment.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/rust/scripts/check-changelog-fragment.mjs b/rust/scripts/check-changelog-fragment.mjs index 7f11344..db3b8df 100644 --- a/rust/scripts/check-changelog-fragment.mjs +++ b/rust/scripts/check-changelog-fragment.mjs @@ -43,8 +43,17 @@ function getChangedFiles() { const baseRef = process.env.GITHUB_BASE_REF || 'main'; console.log(`Comparing against origin/${baseRef}...HEAD`); + // `--relative` makes git report paths relative to the current working + // directory (the language package, e.g. `rust/`, because the CI step sets + // `working-directory`). Without it git prints repo-root-relative paths like + // `rust/src/lib.rs`, which never match the `^src/`, `^tests/`, `^scripts/`, + // `^Cargo.toml$` and `changelog.d/` patterns below. In a monorepo that both + // silently disables the check for real source changes and, worse, makes an + // unrelated repo-root `scripts/` change trip it. See issue #39. try { - const output = exec(`git diff --name-only origin/${baseRef}...HEAD`); + const output = exec( + `git diff --name-only --relative origin/${baseRef}...HEAD` + ); return output ? output.split('\n').filter(Boolean) : []; } catch (error) { console.error(`Git diff failed: ${error.message}`); From 22a0f3e33a45e6f031e2004347681979a441f3ef Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:27:50 +0000 Subject: [PATCH 11/12] chore: remove root debug strays, preserve parser-bug notes in case study, harden parity workflow - Remove tracked debugging leftovers: test_debug.py, python/test_encoder_fix.py, issue_details.json, pr_status.json, and the orphaned .github-workflows-test.yml (a workflow at repo root that GitHub Actions never ran). - Move PARSER_BUG.md into docs/case-studies/issue-39/data/ and record that the links-notation parser bug is fixed in 0.14.0 and already worked around here. - Add least-privilege permissions and a job timeout to the parity workflow, matching the hygiene in the language pipeline templates. Refs #39. --- .github-workflows-test.yml | 71 ------------------- .github/workflows/parity.yml | 6 ++ .../data/links-notation-parser-bug.md | 39 ++++++++++ issue_details.json | 1 - pr_status.json | 1 - python/test_encoder_fix.py | 68 ------------------ test_debug.py | 29 -------- 7 files changed, 45 insertions(+), 170 deletions(-) delete mode 100644 .github-workflows-test.yml rename PARSER_BUG.md => docs/case-studies/issue-39/data/links-notation-parser-bug.md (75%) delete mode 100644 issue_details.json delete mode 100644 pr_status.json delete mode 100644 python/test_encoder_fix.py delete mode 100755 test_debug.py diff --git a/.github-workflows-test.yml b/.github-workflows-test.yml deleted file mode 100644 index f7cfaad..0000000 --- a/.github-workflows-test.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Tests - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test-python: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.13'] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - working-directory: ./python - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - - name: Run tests with coverage - working-directory: ./python - run: | - pytest tests/ -v --cov=link_notation_objects_codec --cov-report=term-missing - - - name: Run linter (ruff) - working-directory: ./python - run: | - ruff check src/ tests/ - continue-on-error: true - - - name: Run type checker (mypy) - working-directory: ./python - run: | - mypy src/ - continue-on-error: true - - test-javascript: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: ['22'] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install dependencies - working-directory: ./js - run: npm install - - - name: Run tests - working-directory: ./js - run: npm test - - - name: Run example - working-directory: ./js - run: npm run example diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 5b5fcda..c45f909 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -10,6 +10,11 @@ on: types: [opened, synchronize, reopened, edited] workflow_dispatch: +# Least-privilege default, mirroring the security workflow in the language +# pipeline templates. This job only reads the repository. +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -18,6 +23,7 @@ jobs: parity: name: Languages Change Together runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 with: diff --git a/PARSER_BUG.md b/docs/case-studies/issue-39/data/links-notation-parser-bug.md similarity index 75% rename from PARSER_BUG.md rename to docs/case-studies/issue-39/data/links-notation-parser-bug.md index 3b7c63c..75eaba8 100644 --- a/PARSER_BUG.md +++ b/docs/case-studies/issue-39/data/links-notation-parser-bug.md @@ -146,3 +146,42 @@ This bug prevents the `lino-objects-codec` library from properly encoding/decodi ## Requested Action Please fix the Python `links-notation` parser to correctly handle self-referenced object definitions when they appear as values inside pairs, matching the behavior of the JavaScript implementation. + +--- + +## Status update (issue #39 investigation, 2026-08-20) + +This document was captured during earlier work on the compact format. During the +issue #39 investigation it was re-verified and its resolution established, so no +new upstream issue needs to be filed: + +- **Still present in `links-notation` 0.11.2** (the version the Python package + currently pins via `links-notation>=0.11.0,<0.12.0`). Minimal reproduction with + the raw parser: + + ```python + import links_notation as ln + ln.Parser().parse("(list (obj_1: list a b))") + # -> mis-parsed: the nested `(id: ...)` definition corrupts the enclosing link + ``` + +- **Fixed in `links-notation` 0.14.0.** The same inputs round-trip correctly + there: + + ```python + ln.Parser().parse("(key (obj_1: list a b))") # -> (key (obj_1: list a b)) + ln.Parser().parse("(list (obj_1: list a b))") # -> (list (obj_1: list a b)) + ``` + +- **Impact on this repository is already contained.** The compact encoder was + changed (commits `a769d70`, `97706dc`) to emit sibling `obj_N` definitions with + back-references instead of nesting a definition inside a pair value, so the + round-trip works even on the buggy 0.11.2 parser. The readable format, now the + default, is a plain tree and never uses the `(id: ...)` syntax at all, so it is + unaffected regardless of parser version. + +- **Recommended follow-up (out of scope for issue #39):** bump the Python pin to + `links-notation>=0.14.0` (Rust already uses 0.14.0) and, once on the fixed + parser, the compact encoder's sibling-definition workaround could be simplified. + This is a dependency upgrade with its own API-compatibility surface and is + tracked here rather than bundled into the readable-format change. diff --git a/issue_details.json b/issue_details.json deleted file mode 100644 index d6b330e..0000000 --- a/issue_details.json +++ /dev/null @@ -1 +0,0 @@ -{"body":"```\nCircular references use special ref links: (ref obj_0)\n```\n\nNow I see in readme we add special marker/reference/keyword `ref`. And it is redundant.\n\nFor example:\n\n```js\nconst obj = {\n \"self\": obj\n \"other\": { \"1\": 1, \"2\": 2 }\n};\n```\n\nSelf reference should be translated as (or similar):\n\n```\n(obj: \n (self obj)\n (other (\n (1 1)\n (2 2)\n ))\n)\n```\n\nto links notation\n\nHow to read links notation:\n\n```\n(self-reference: first-reference second-reference ...)\n```\n\nImplement new style in both JS and Python versions.","comments":[],"title":"Instead of `ref` reference/marker use built-in references in links notation"} diff --git a/pr_status.json b/pr_status.json deleted file mode 100644 index 4209f82..0000000 --- a/pr_status.json +++ /dev/null @@ -1 +0,0 @@ -{"body":"## Summary\n\nThis PR implements the use of built-in references in links notation as requested in issue #5, replacing the `ref` marker/keyword with native self-reference syntax.\n\n## Changes\n\n### Old Format (using `ref` keyword)\n```\n(dict obj_0 ((str c2VsZg==) (ref obj_0)))\n```\n\n### New Format (using built-in references)\n```\n(dict obj_0 ((str c2VsZg==) obj_0))\n```\n\n## Implementation Details\n\n### Python (`python/src/link_notation_objects_codec/codec.py`)\n- Removed `TYPE_REF` constant\n- Implemented two-pass encoding:\n 1. First pass identifies objects referenced multiple times or circularly\n 2. Second pass marks containers that contain objects with IDs\n- Collections WITH IDs use format: `(dict obj_0 (key val) ...)` or `(list obj_0 item ...)`\n- Collections WITHOUT IDs use format: `(dict (key val) ...)` or `(list item ...)`\n- References use direct Link IDs: `Link(link_id=ref_id)` → `obj_0` (not `(ref obj_0)`)\n- Updated decoder to handle both formats\n- Reverted `links-notation` dependency to `>=0.9.0,<0.10.0` for Python 3.9-3.12 compatibility\n\n### JavaScript (`js/src/codec.js`)\n- Removed `TYPE_REF` constant\n- Applied same two-pass encoding logic as Python\n- Collections WITH IDs: `(object obj_0 (key val) ...)` or `(array obj_0 item ...)`\n- Collections WITHOUT IDs: `(object (key val) ...)` or `(array item ...)`\n- References use direct Link IDs: `new Link(refId)` → `obj_0` (not `(ref obj_0)`)\n- Updated decoder to match Python implementation\n\n### Documentation (`README.md`)\n- Updated \"How It Works\" section to reflect new reference format\n\n## Test Results\n\n### Python\n- **47 of 47 tests passing** (100%) ✅\n- All circular reference tests passing\n- All shared object tests passing\n\n### JavaScript\n- **57 of 57 tests passing** (100%) ✅\n- All circular reference tests passing \n- All shared object tests passing\n\n## Format Examples\n\n### Self-referencing object:\n```python\nobj = {}\nobj[\"self\"] = obj\nencode(obj) # Returns: (dict obj_0 ((str c2VsZg==) obj_0))\n```\n\n### Mutual references:\n```python\nlist1 = [1, 2]\nlist2 = [3, 4]\nlist1.append(list2)\nlist2.append(list1)\nencode(list1) # Returns: (list obj_0 (int 1) (int 2) (list obj_1 (int 3) (int 4) obj_0))\n```\n\n### Simple collections (no shared refs):\n```python\n[1, 2, 3] # Encodes as: (list (int 1) (int 2) (int 3))\n{\"a\": 1} # Encodes as: (dict ((str YQ==) (int 1)))\n```\n\n## Key Improvements\n\n1. **Built-in References**: Uses `obj_0` directly instead of `(ref obj_0)` as required\n2. **Reduced Output**: Simple collections without shared references don't get unnecessary IDs\n3. **Parser Compatibility**: Format `(list obj_0 ...)` works correctly with links-notation 0.9.0, avoiding parser bugs with `:` syntax\n4. **All Tests Passing**: Both Python and JavaScript implementations now pass 100% of tests\n\nFixes #5\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude ","comments":[{"id":"IC_kwDOQWrSmc7S7ZT7","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## 🤖 Solution Draft Log\nThis log file contains the complete execution trace of the AI solution draft process.\n\n💰 **Cost estimation:**\n- Public pricing estimate: $7.982161 USD\n- Calculated by Anthropic: $4.626320 USD\n- Difference: $-3.355841 (-42.04%)\n📎 **Log file uploaded as GitHub Gist** (750KB)\n🔗 [View complete solution draft log](https://gist.github.com/konard/651f656f195729de16cd8805bcbe851b)\n---\n*Now working session is ended, feel free to review and add any feedback on the solution draft.*","createdAt":"2025-11-16T14:02:01Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3538785531","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S7ZWN","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## 🔄 Auto-restart 1/3\n\nDetected uncommitted changes from previous run. Starting new session to review and commit them.\n\n**Uncommitted files:**\n```\n?? js/package-lock.json\n?? python/experiments/debug_decode.py\n?? python/experiments/test_implementation.py\n?? python/experiments/test_implementation2.py\n?? python/experiments/test_mutual_dicts.py\n?? python/experiments/test_new_reference_style.py\n?? python/experiments/test_new_reference_style2.py\n?? python/experiments/test_roundtrip.py\n?? python/experiments/test_shared_object.py\n```\n\n---\n*Auto-restart will stop after changes are committed or after 2 more iterations. Please wait until working session will end and give your feedback.*","createdAt":"2025-11-16T14:02:09Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3538785677","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S7er7","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## ✅ Implementation Complete\n\nAll tasks for issue #5 have been completed successfully.\n\n### Summary of Changes\n\n**✅ Removed `ref` keyword/marker**\n- Removed `TYPE_REF` constant from both Python and JavaScript implementations\n- References now use built-in links notation syntax directly\n\n**✅ New Reference Format**\n- **Old format**: `(ref obj_0)` \n- **New format**: `obj_0` (direct built-in reference)\n- Collections with IDs: `(dict obj_0 ...)` or `(list obj_0 ...)` instead of using `:` syntax\n- Simple collections without shared refs: `(dict ...)` or `(list ...)` (no ID needed)\n\n**✅ Implementation Details**\n- Two-pass encoding algorithm identifies which objects need IDs\n- Optimized to only assign IDs to objects that are referenced multiple times or circularly\n- Backward-compatible decoder handles both old and new formats\n- Updated README documentation\n\n### Test Results\n\n**Python**: 47/47 tests passing (100%) ✅\n**JavaScript**: 57/57 tests passing (100%) ✅\n**CI**: All checks passing ✅\n\n### Example Output\n\n```python\nobj = {}\nobj[\"self\"] = obj\nencode(obj) # Returns: (dict obj_0 ((str c2VsZg==) obj_0))\n```\n\nNotice `obj_0` is used directly instead of `(ref obj_0)` as required!\n\n### Commits\n1. Initial implementation task details\n2. Python codec implementation with built-in references\n3. JavaScript codec implementation with built-in references \n4. Reverted task details commit\n5. Fixed parser compatibility using hybrid format\n6. Added experiment scripts for debugging/testing\n\nAll requirements from issue #5 have been met. Ready for review! 🎉","createdAt":"2025-11-16T14:25:54Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3538807547","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S7exa","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## 🤖 Solution Draft Log\nThis log file contains the complete execution trace of the AI solution draft process.\n\n💰 **Cost estimation:**\n- Public pricing estimate: $1.658405 USD\n- Calculated by Anthropic: $0.779495 USD\n- Difference: $-0.878910 (-53.00%)\n📎 **Log file uploaded as GitHub Gist** (1662KB)\n🔗 [View complete solution draft log](https://gist.github.com/konard/f580fb4bb811f158c2a940a8e81f61d8)\n---\n*Now working session is ended, feel free to review and add any feedback on the solution draft.*","createdAt":"2025-11-16T14:26:19Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3538807898","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S7357","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"```\r\nobj = {}\r\nobj[\"self\"] = obj\r\nencode(obj) # Returns: (dict obj_0 ((str c2VsZg==) obj_0))\r\n```\r\n\r\nThat is wrong, it should at least be like this:\r\n\r\n```\r\n(obj_0: dict ((str c2VsZg==) obj_0))\r\n```\r\n\r\nWe need to use real references from Links Notation:\r\n\r\n```\r\n(self-reference: reference-1 reference-2 ...)\r\n```\r\n\r\nIn that case `obj_0` is self reference.\r\n\r\nI also want to have explict links notation check after each encode in tests, so we will be able to clearly see how encode works.\r\n\r\nAnd of cource we still need to check that round trip works: `... decode(encode(...))`\r\n\r\nSo please read again my original requirements, and update implemention accordingly.","createdAt":"2025-11-16T16:13:49Z","includesCreatedEdit":true,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3538910843","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S74HY","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"🤖 **AI Work Session Started**\n\nStarting automated work session at 2025-11-16T16:14:24.357Z\n\nThe PR has been converted to draft mode while work is in progress.\n\n_This comment marks the beginning of an AI work session. Please wait working session to finish, and provide your feedback._","createdAt":"2025-11-16T16:14:26Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3538911704","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S76wo","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## 🤖 Solution Draft Log\nThis log file contains the complete execution trace of the AI solution draft process.\n\n💰 **Cost estimation:**\n- Public pricing estimate: $6.036427 USD\n- Calculated by Anthropic: $2.599645 USD\n- Difference: $-3.436782 (-56.93%)\n📎 **Log file uploaded as GitHub Gist** (476KB)\n🔗 [View complete solution draft log](https://gist.github.com/konard/014c23376f85df5ff9272790be45ea20)\n---\n*Now working session is ended, feel free to review and add any feedback on the solution draft.*","createdAt":"2025-11-16T16:27:29Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3538922536","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8TUk","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"Please test only the latest versions of Python and JavaScript, and make sure they will pass.\r\n","createdAt":"2025-11-16T17:44:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539023140","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8TsC","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"🤖 **AI Work Session Started**\n\nStarting automated work session at 2025-11-16T17:44:46.918Z\n\nThe PR has been converted to draft mode while work is in progress.\n\n_This comment marks the beginning of an AI work session. Please wait working session to finish, and provide your feedback._","createdAt":"2025-11-16T17:44:48Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539024642","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8cpj","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## ✅ All CI Checks Passing\n\nFixed the issues preventing CI from passing:\n\n### Problems Identified\n1. **Dependency Version Mismatch**: `pyproject.toml` required `links-notation>=0.11.0,<0.12.0`, but only version 0.9.0 is available for Python 3.8-3.12\n2. **Incorrect Encoder Format**: Both Python and JavaScript encoders were using the colon format `(obj_0: dict ...)` which doesn't work with links-notation 0.9.0 parser\n\n### Fixes Applied\n1. ✅ Updated `python/pyproject.toml` to require `links-notation>=0.9.0,<0.10.0`\n2. ✅ Fixed Python encoder to use `(dict obj_0 ...)` format instead of `(obj_0: dict ...)`\n3. ✅ Fixed JavaScript encoder to use `(object obj_0 ...)` format instead of `(obj_0: object ...)`\n4. ✅ Removed obsolete decoder code that handled the old colon format\n5. ✅ Updated test expectations to match the new format\n\n### Test Results\n- **Python**: All 47 tests passing ✅\n- **JavaScript**: All 57 tests passing ✅\n- **CI**: All checks passing for Python 3.8, 3.9, 3.10, 3.11, 3.12 and Node.js 18, 20, 22 ✅\n\nThe PR is now ready for review with all tests passing and proper compatibility with links-notation 0.9.0.","createdAt":"2025-11-16T17:52:09Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539061347","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8czE","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## 🤖 Solution Draft Log\nThis log file contains the complete execution trace of the AI solution draft process.\n\n💰 **Cost estimation:**\n- Public pricing estimate: $2.938524 USD\n- Calculated by Anthropic: $1.483438 USD\n- Difference: $-1.455087 (-49.52%)\n📎 **Log file uploaded as GitHub Gist** (333KB)\n🔗 [View complete solution draft log](https://gist.github.com/konard/db1f6ffbde6f3d3443b9f5adb1b468a5)\n---\n*Now working session is ended, feel free to review and add any feedback on the solution draft.*","createdAt":"2025-11-16T17:52:42Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539061956","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8j52","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"```\r\n[Tests / test-javascript (18) (pull_request)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632913/job/55529222386?pr=6)\r\nTests / test-javascript (18) (pull_request)Successful in 10s\r\n[Tests / test-javascript (18) (push)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632497/job/55529221531?pr=6)\r\nTests / test-javascript (18) (push)Successful in 11s\r\n[Tests / test-javascript (20) (pull_request)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632913/job/55529222392?pr=6)\r\nTests / test-javascript (20) (pull_request)Successful in 9s\r\n[Tests / test-javascript (20) (push)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632497/job/55529221530?pr=6)\r\nTests / test-javascript (20) (push)Successful in 10s\r\n[Tests / test-javascript (22) (pull_request)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632913/job/55529222382?pr=6)\r\nTests / test-javascript (22) (pull_request)Successful in 12s\r\n[Tests / test-javascript (22) (push)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632497/job/55529221535?pr=6)\r\nTests / test-javascript (22) (push)Successful in 15s\r\n[Tests / test-python (3.8) (pull_request)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632913/job/55529222390?pr=6)\r\nTests / test-python (3.8) (pull_request)Successful in 27s\r\n[Tests / test-python (3.8) (push)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632497/job/55529221548?pr=6)\r\nTests / test-python (3.8) (push)Successful in 19s\r\n[Tests / test-python (3.9) (pull_request)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632913/job/55529222399?pr=6)\r\nTests / test-python (3.9) (pull_request)Successful in 17s\r\n[Tests / test-python (3.9) (push)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632497/job/55529221556?pr=6)\r\nTests / test-python (3.9) (push)Successful in 15s\r\n[Tests / test-python (3.10) (pull_request)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632913/job/55529222405?pr=6)\r\nTests / test-python (3.10) (pull_request)Successful in 16s\r\n[Tests / test-python (3.10) (push)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632497/job/55529221550?pr=6)\r\nTests / test-python (3.10) (push)Successful in 16s\r\n[Tests / test-python (3.11) (pull_request)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632913/job/55529222407?pr=6)\r\nTests / test-python (3.11) (pull_request)Successful in 21s\r\n[Tests / test-python (3.11) (push)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632497/job/55529221549?pr=6)\r\nTests / test-python (3.11) (push)Successful in 15s\r\n[Tests / test-python (3.12) (pull_request)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632913/job/55529222418?pr=6)\r\nTests / test-python (3.12) (pull_request)Successful in 20s\r\n[Tests / test-python (3.12) (push)](https://github.com/link-foundation/lino-objects-codec/actions/runs/19409632497/job/55529221546?pr=6)\r\nTests / test-python (3.12) (push)Successful in 15s\r\n```\r\n\r\nWe have duplication in tests runs (here in pull request we don't need to execute also tests for push in default branch).\r\n\r\nAnd I asked to test only latest versions of Python and JavaScritp, that will speed up iteration for future.","createdAt":"2025-11-16T18:19:38Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539091062","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8kF7","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"🤖 **AI Work Session Started**\n\nStarting automated work session at 2025-11-16T18:20:37.881Z\n\nThe PR has been converted to draft mode while work is in progress.\n\n_This comment marks the beginning of an AI work session. Please wait working session to finish, and provide your feedback._","createdAt":"2025-11-16T18:20:40Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539091835","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8nn_","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## ✅ Updated to Built-in Self-Reference Syntax\n\nI've updated the implementation to use the proper built-in self-reference syntax as requested:\n\n### Format Change\n\n**Old format** (using separate marker):\n```\n(dict obj_0 ((str c2VsZg==) obj_0))\n```\n\n**New format** (using built-in self-reference):\n```\n(obj_0: dict ((str c2VsZg==) obj_0))\n```\n\n### Changes Made\n\n1. ✅ **Updated encoders** - Both Python and JavaScript now use `(obj_0: type ...)` format\n2. ✅ **Updated decoders** - Both support the new format with backward compatibility\n3. ✅ **Updated test expectations** - All test assertions now check for the new format\n4. ✅ **Updated CI** - Now tests only latest Python (3.12) and Node.js (22) as requested\n5. ✅ **Updated dependency** - Python now requires `links-notation>=0.11.0,<0.12.0`\n\n### Test Results\n\n**JavaScript**: 57/57 tests passing (100%) ✅\n\n**Python**: 43/47 tests passing (91%) ⚠️\n\n### Known Issue with Nested Self-References\n\nThere are 4 failing Python tests, all involving **nested mutual dict references**. Investigation shows this is a **parsing limitation in the links-notation library** (tested with v0.11.2).\n\n**Example that fails to parse correctly:**\n```\n(obj_0: dict ((str bmFtZQ==) (str ZGljdDE=)) ((str b3RoZXI=) (obj_1: dict ((str bmFtZQ==) (str ZGljdDI=)) ((str b3RoZXI=) obj_0))))\n```\n\nWhen parsed, the second pair's ID becomes `'(str b3RoZXI=) (obj_1'` instead of being properly recognized as a pair containing a self-referenced dict.\n\n**What works:**\n- ✅ Simple self-references: `(obj_0: dict ((str c2VsZg==) obj_0))`\n- ✅ Self-referencing lists\n- ✅ Shared object references\n- ✅ All JavaScript tests (different structure avoids the parsing issue)\n\n**What doesn't work:**\n- ❌ Nested self-references in dicts (mutual references between multiple dicts)\n\n### Questions\n\nCould you please clarify the expected format? In your comment you showed:\n```\n(obj_0: dict obj_0 ((str c2VsZg==) obj_0))\n```\n\nThis has `obj_0` appearing twice after `dict`. Should the format be:\n- `(obj_0: dict ((str c2VsZg==) obj_0))` - what I implemented\n- `(obj_0: dict obj_0 ((str c2VsZg==) obj_0))` - what your comment suggested\n\nOr is there a different approach we should take for nested structures?","createdAt":"2025-11-16T18:32:51Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539106303","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8pOX","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## 🤖 Solution Draft Log\nThis log file contains the complete execution trace of the AI solution draft process.\n\n💰 **Cost estimation:**\n- Public pricing estimate: $4.851742 USD\n- Calculated by Anthropic: $3.190540 USD\n- Difference: $-1.661202 (-34.24%)\n📎 **Log file uploaded as GitHub Gist** (445KB)\n🔗 [View complete solution draft log](https://gist.github.com/konard/a841eabf7af9972c814d9263643df650)\n---\n*Now working session is ended, feel free to review and add any feedback on the solution draft.*","createdAt":"2025-11-16T18:34:07Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539112855","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8sTS","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"Please note the comment (https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3538910843), we still don't use real references as I see in code.\r\n\r\nPlease read docs and code for https://github.com/link-foundation/links-notation to understand how it works.\r\n\r\n```\r\n(obj_0: dict ((str c2VsZg==) obj_0)) - what I implemented\r\n```\r\n\r\nThat is much better, and that is exactly what is needed. Double check other places to do similar thing in both JS and Python.\r\n\r\nAlso we use latest versions of Python and JS in CI, we still have duplicated execution for both languages, we need only execute pull request tests for pull requests (no need also execute tests that should be executed on push to default branch) we have wrong configuration of CI workflows.","createdAt":"2025-11-16T18:48:15Z","includesCreatedEdit":true,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539125458","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8sqy","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"🤖 **AI Work Session Started**\n\nStarting automated work session at 2025-11-16T18:50:10.532Z\n\nThe PR has been converted to draft mode while work is in progress.\n\n_This comment marks the beginning of an AI work session. Please wait working session to finish, and provide your feedback._","createdAt":"2025-11-16T18:50:12Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539126962","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8w2w","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## Progress Update\n\nI've identified and partially fixed the issue with the built-in self-reference syntax `(obj_0: dict ...)`.\n\n### Root Cause\n\nThe links-notation parser (versions 0.9.0-0.11.2) has a **parsing bug** when handling nested self-referenced structures within pairs. For example:\n\n```\n((str b3RoZXI=) (obj_1: dict ...))\n```\n\nThe parser incorrectly parses the pair's ID as `(str b3RoZXI=) (obj_1` instead of recognizing it as two separate elements.\n\n### Solution Implemented\n\nChanged the encoder to output **multiple top-level links separated by newlines** instead of nesting self-referenced definitions:\n\n**Old (broken) format:**\n```\n(obj_0: dict ((str other) (obj_1: dict ((str name) (str dict2)))))\n```\n\n**New (working) format:**\n```\n(obj_0: dict ((str bmFtZQ==) (str ZGljdDE=)) ((str b3RoZXI=) obj_1))\n(obj_1: dict ((str bmFtZQ==) (str ZGljdDI=)) ((str b3RoZXI=) obj_0))\n```\n\nThe newline separator is crucial - the parser requires it to recognize multiple top-level links.\n\n### Current Status\n\n**Python**: ✅ 43/47 tests passing (91%) \n**JavaScript**: ⏳ Not yet updated\n\nThe 4 failing tests are edge cases involving:\n1. Test expectations checking for old nested format\n2. Some complex nested circular structures\n\n### Questions for Maintainer\n\n1. Is the multi-link output format acceptable, or should we find a different approach?\n2. Should I continue fixing the remaining Python test edge cases, or first apply the same changes to JavaScript?\n3. Are there any parser updates planned for links-notation that would better handle nested self-references?\n\n### Changes Made\n\n- ✅ Updated Python encoder to use multi-link output\n- ✅ Updated decoder with forward reference resolution \n- ✅ Fixed circular reference detection\n- ✅ Updated dependency to `links-notation>=0.11.0,<0.12.0`\n- ✅ Committed progress\n\nLet me know how you'd like me to proceed!","createdAt":"2025-11-16T19:04:31Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539144112","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8xB1","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## 🤖 Solution Draft Log\nThis log file contains the complete execution trace of the AI solution draft process.\n\n💰 **Cost estimation:**\n- Public pricing estimate: $6.301091 USD\n- Calculated by Anthropic: $3.063184 USD\n- Difference: $-3.237907 (-51.39%)\n📎 **Log file uploaded as GitHub Gist** (497KB)\n🔗 [View complete solution draft log](https://gist.github.com/konard/6280688c2b399d1d6cc299284e040f0c)\n---\n*Now working session is ended, feel free to review and add any feedback on the solution draft.*","createdAt":"2025-11-16T19:05:24Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539144821","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8xDS","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## 🔄 Auto-restart 1/3\n\nDetected uncommitted changes from previous run. Starting new session to review and commit them.\n\n**Uncommitted files:**\n```\n?? pr-details.json\n```\n\n---\n*Auto-restart will stop after changes are committed or after 2 more iterations. Please wait until working session will end and give your feedback.*","createdAt":"2025-11-16T19:05:32Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539144914","viewerDidAuthor":true},{"id":"IC_kwDOQWrSmc7S8z-I","author":{"login":"konard"},"authorAssociation":"MEMBER","body":"## Investigation Update\n\nI've identified the root cause of the remaining test failures.\n\n### Issue\n\nThe links-notation parser (version 0.11.2 with Python 3.13) has a **parsing bug with nested self-references inside pairs**.\n\n**Example that fails:**\n```\n(obj_0: dict ((str b3RoZXI=) (obj_1: dict ((str bmFtZQ==) (str ZGljdDI=)))))\n```\n\n**Parser output:**\nThe second pair's ID is incorrectly parsed as `(str b3RoZXI=) (obj_1` instead of recognizing it as a pair containing `(obj_1: dict ...)`.\n\n### What Works\n\n✅ Simple self-references: `(obj_0: dict ((str c2VsZg==) obj_0))`\n✅ References at top level: `(obj_0: list (int 1) (int 2) obj_1)`\n❌ Self-references nested in pairs: `((key) (obj_1: dict ...))`\n\n### Test Results\n\n- **Passing** (42/47): All tests with simple self-references and top-level nested references\n- **Failing** (5/47): Tests with self-references nested inside dict pairs\n\n### Question\n\nGiven this parser limitation, should we:\n1. Revert to the multi-link format (separate top-level definitions)?\n2. Wait for a parser fix in links-notation?\n3. Use a different nesting approach?\n\nThe multi-link format would be:\n```\n(obj_0: dict ((str bmFtZQ==) (str ZGljdDE=)) ((str b3RoZXI=) obj_1))\n(obj_1: dict ((str bmFtZQ==) (str ZGljdDI=)) ((str b3RoZXI=) obj_0))\n```\n\nThis avoids nesting self-referenced dicts inside pairs and works correctly with the parser.","createdAt":"2025-11-16T19:17:23Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/link-foundation/lino-objects-codec/pull/6#issuecomment-3539156872","viewerDidAuthor":true}],"statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2025-11-16T19:15:20Z","conclusion":"FAILURE","detailsUrl":"https://github.com/link-foundation/lino-objects-codec/actions/runs/19410711869/job/55531651592","name":"test-python (3.13)","startedAt":"2025-11-16T19:15:03Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2025-11-16T19:15:11Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/link-foundation/lino-objects-codec/actions/runs/19410711869/job/55531651596","name":"test-javascript (22)","startedAt":"2025-11-16T19:15:03Z","status":"COMPLETED","workflowName":"Tests"}],"title":"Implement built-in references in links notation per issue #5"} diff --git a/python/test_encoder_fix.py b/python/test_encoder_fix.py deleted file mode 100644 index 048d88a..0000000 --- a/python/test_encoder_fix.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -"""Test the updated encoder implementation.""" - -import sys - -sys.path.insert(0, "src") - -from link_notation_objects_codec import encode, decode - -# Test 1: Simple self-reference -print("Test 1: Simple self-reference") -obj = {} -obj["self"] = obj -encoded = encode(obj) -print(f" Encoded: {encoded}") -print(f" Lines: {len(encoded.split(chr(10)))}") -decoded = decode(encoded) -print(f" Decoded correctly: {decoded is decoded.get('self')}") -print() - -# Test 2: Mutual reference dicts -print("Test 2: Mutual reference dicts") -dict1 = {"name": "dict1"} -dict2 = {"name": "dict2"} -dict1["other"] = dict2 -dict2["other"] = dict1 - -encoded = encode(dict1) -print(f" Encoded:\n{encoded}") -print(f" Lines: {len(encoded.split(chr(10)))}") -decoded = decode(encoded) -print(f" Decoded has 'name': {'name' in decoded}") -print(f" Decoded has 'other': {'other' in decoded}") -if "other" in decoded and "other" in decoded["other"]: - print(f" Circular ref works: {decoded['other']['other'] is decoded}") -print() - -# Test 3: List with multiple references to same object -print("Test 3: List with multiple references to same object") -shared = {"shared": "value"} -lst = [shared, shared, shared] - -encoded = encode(lst) -print(f" Encoded:\n{encoded}") -print(f" Lines: {len(encoded.split(chr(10)))}") -decoded = decode(encoded) -print(f" Decoded type: {type(decoded)}") -print(f" Length: {len(decoded)}") -if len(decoded) == 3: - print(f" All three are same object: {decoded[0] is decoded[1] is decoded[2]}") -print() - -# Test 4: Complex circular structure -print("Test 4: Complex circular structure") -root = {"name": "root", "children": []} -child1 = {"name": "child1", "parent": root} -child2 = {"name": "child2", "parent": root} -root["children"].extend([child1, child2]) - -encoded = encode(root) -print(f" Encoded:\n{encoded}") -print(f" Lines: {len(encoded.split(chr(10)))}") -decoded = decode(encoded) -print(f" Decoded has 'children': {'children' in decoded}") -if "children" in decoded and len(decoded["children"]) > 0: - print(f" Children count: {len(decoded['children'])}") - if "parent" in decoded["children"][0]: - print(f" Circular ref works: {decoded['children'][0]['parent'] is decoded}") diff --git a/test_debug.py b/test_debug.py deleted file mode 100755 index a8c478d..0000000 --- a/test_debug.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -import sys -sys.path.insert(0, 'python/src') - -from link_notation_objects_codec import encode, decode - -# Test mutual reference dicts -dict1 = {"name": "dict1"} -dict2 = {"name": "dict2"} -dict1["other"] = dict2 -dict2["other"] = dict1 - -print("=== Encoding ===") -encoded = encode(dict1) -print(f"Encoded:\n{encoded}") -print() - -print("=== Decoding ===") -try: - decoded = decode(encoded) - print(f"Decoded: {decoded}") - print(f"decoded['name']: {decoded.get('name', 'MISSING')}") - print(f"'other' in decoded: {'other' in decoded}") - if 'other' in decoded: - print(f"decoded['other']: {decoded['other']}") -except Exception as e: - print(f"Error during decoding: {e}") - import traceback - traceback.print_exc() From 5d193739bfa89fd381b914e49bfbee5748775b36 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:31:14 +0000 Subject: [PATCH 12/12] docs(case-study): add deep analysis for issue #39 --- docs/case-studies/issue-39/README.md | 239 +++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/case-studies/issue-39/README.md diff --git a/docs/case-studies/issue-39/README.md b/docs/case-studies/issue-39/README.md new file mode 100644 index 0000000..197d78c --- /dev/null +++ b/docs/case-studies/issue-39/README.md @@ -0,0 +1,239 @@ +# Case Study — Issue #39: "Apply it to all languages and docs" + +- **Issue:** [#39](https://github.com/link-foundation/lino-objects-codec/issues/39) + (bug), opened 2026-08-20T06:09:11Z by @konard +- **Pull request:** [#40](https://github.com/link-foundation/lino-objects-codec/pull/40) + on branch `issue-39-e53c893293ed` +- **Predecessor:** [#37](https://github.com/link-foundation/lino-objects-codec/issues/37) + → [PR #38](https://github.com/link-foundation/lino-objects-codec/pull/38) + (readable format, **Rust only**) +- **Raw data:** [`./data`](./data) (issue/PR JSON, PR #38 diff, CI run logs, the + preserved `links-notation` parser-bug note) + +This document reconstructs the sequence of events, enumerates every requirement in +the issue, gives the root cause of each problem, and records the solution applied +(or recommended) for each — including a full comparison against the four CI/CD +pipeline templates and a survey of the existing components reused. + +--- + +## 1. Timeline / sequence of events + +| When (UTC) | Event | +|---|---| +| 2026-08-20 03:03 | Issue **#37** filed: `encode()` base64-encodes every string on one line, so `.lino` files are not human-readable; the readable formatter already exists but is not the default. | +| 2026-08-20 05:47 | **PR #38** merged: `feat(rust): make readable indented Links Notation the default encoding`. Touches **only** `rust/` (plus a shared `README.md` and Rust experiments). Ships as Rust `v0.3.0`. | +| 2026-08-20 06:09 | Issue **#39** filed: PR #38 applied the change to Rust only — apply it to **all languages and docs**, make CI fail when languages drift, reuse template best practices, produce a case study, and report any related upstream issues. | +| 2026-08-20 06:10 | **PR #40** opened `[WIP]` for branch `issue-39-e53c893293ed`. | +| 2026-08-20 06:xx | Investigation: data downloaded into `docs/case-studies/issue-39/data/`; readable format ported to JS, Python and C#; shared conformance fixtures created; a cross-language compact-format boolean interop defect found and fixed; language-parity CI gate added; docs updated; release fragments added; a latent Rust changelog-check bug found and fixed. | + +**Why PR #38 was Rust-only.** Issue #37 was scoped to the symptom the author hit +in Rust (`encode()` output), and PR #38 fixed it there. The four language +implementations live in one monorepo but each has its own CI workflow gated by a +`paths:` filter (`rust/**`, `js/**`, …). Nothing in the pipeline required the +other three languages to move together, so a single-language change was normal and +CI-green. Issue #39 is the direct consequence: bring the other three languages up +to parity **and remove the structural reason the drift was possible.** + +--- + +## 2. Requirements (verbatim decomposition) + +The issue body contains seven distinct requirements: + +1. **Apply the readable format to all languages and docs.** PR #38 changed only + Rust; JavaScript, Python and C# must default to the same readable indented + Links Notation, and the documentation must match. +2. **Make single-language changes fail CI.** "any changes in code for single + language without change in all of them will fail the CI/CD on pull requests. So + all languages are always updated at the same time." +3. **Reuse best practices from the four CI/CD templates** (`js`, `rust`, `python`, + `csharp` `-ai-driven-development-pipeline-template`); compare the full file tree + of GitHub workflow / CI scripts; **if the same issue exists in a template, + report it there too.** +4. **Download the issue data and write a deep case study** under + `./docs/case-studies/issue-{id}`: timeline, requirement list, root causes, + solution plans, and a survey of existing components — plus online research. +5. **If data is insufficient for a root cause, add debug output / verbose mode** so + the next iteration can find it. +6. **Report issues to any related repository** with reproducible examples, + workarounds and fix suggestions; **fully apply fixes everywhere** a problem + appears (not just one spot). +7. **Do it all in this single PR**, iterating until every requirement is done. + +--- + +## 3. Root cause and solution per requirement + +### R1 — Readable format in all languages + docs + +- **Root cause.** The readable formatter (`readable.rs`) and the "readable is the + default" wiring existed only in Rust after PR #38. JavaScript, Python and C# had + no `readable.*` module and still defaulted `encode()` to the compact, + base64-per-string single-line form. +- **Solution.** Ported the readable format to the other three languages as the + default of `encode`/`decode` (`js/src/readable.js`, + `python/src/link_notation_objects_codec/readable.py`, + `csharp/src/Lino.Objects.Codec/Readable.cs`). The default now emits one `( )` + construct for objects and arrays at every level; `key value` lines form objects, + bare-value lines form arrays; strings are quoted, numbers / `true` / `false` / + `null` are bare, `NaN`/`Infinity`/`-Infinity` are written literally, empty array + is `()`, empty object is `(`+newline+`)`, and only values containing control + characters are marked individually as `(base64 "…")`. + - **Cross-language guarantee.** A shared, language-agnostic fixture set — + [`fixtures/readable-format/cases.json`](../../../fixtures/readable-format/cases.json) + (39 cases, tagged value encoding, per-language `skip` map) — is executed by a + conformance harness in every language (`readable_conformance.rs`, + `test_readable_conformance.*`, `ReadableConformanceTests.cs`). It asserts each + case **encodes to byte-identical text** and **decodes back** in all four + implementations. This is what makes "applied to all languages" machine-checked + rather than a claim. + - **Docs.** All five READMEs (root + four languages) were updated: the misleading + "UTF-8 support using base64 encoding" claim removed; an Output Formats table, + the `(base64 "…")` marker, readable-vs-compact "How It Works", the + cycle/identity rule, and a Debugging section added. + +### R2 — Single-language changes must fail CI + +- **Root cause.** Each language's workflow uses a `paths:` filter, so a PR that + touches only `rust/**` runs only the Rust workflow. **No** job observed the whole + tree, so nothing could notice that the other three languages had not moved. That + is exactly how PR #38 stayed green while changing one language. +- **Solution.** Added a dedicated **parity gate** that has *no* `paths:` filter and + therefore always runs: + - [`scripts/check-language-parity.mjs`](../../../scripts/check-language-parity.mjs) + computes which languages changed (by matching the PR diff against each + language's `src/` prefix) and fails unless the count is 0 or all 4. + - [`.github/workflows/parity.yml`](../../../.github/workflows/parity.yml) runs the + helper's own unit tests + ([`check-language-parity.test.mjs`](../../../scripts/check-language-parity.test.mjs)) + and then the gate, on every PR. + - An intentional single-language change opts out with `[skip-parity]` in the PR + title or body — an explicit, reviewable escape hatch rather than a silent one. + +### R3 — Reuse best practices from the templates + +- **Findings.** See §4 for the full file-tree comparison. Concrete outcomes: + - Adopted the templates' workflow hygiene into the new parity workflow: + top-level least-privilege `permissions: contents: read`, `timeout-minutes`, and + a `concurrency` group. + - Documented (with ready-to-use YAML) the templates' `security.yml` + (CodeQL + dependency-review) and `links.yml` (link checker) as recommended + follow-ups; they are intentionally **not** merged here because they require + repository code-scanning settings that cannot be verified from the PR and would + risk first-run CI failures — which would contradict this issue's own "so we + don't have more CI/CD errors in the future" goal. + - **No spurious template issue was filed.** The one CI bug found (see R6, the + Rust changelog check) is specific to *this* repository's monorepo adaptation of + a template script; the templates are single-language repos where the same code + is correct, so there is nothing to report upstream for it. + +### R4 — Case study + +- This document, plus [`./data`](./data). Online research is summarized in §5. + +### R5 — Debug output / verbose mode + +- **Root cause of the gap.** The codec had no tracing, so a cross-language + discrepancy could only be chased by hand. +- **Solution.** Added opt-in tracing to all four languages, off by default, enabled + by the `LINO_CODEC_DEBUG` environment variable (`1`, `true`, `yes`, `on`) or from + code (`set_debug_enabled` / `setDebugEnabled` / `CodecDebug.SetEnabled` / + `debug::set_debug_enabled`). Trace lines go to stderr prefixed `[lino-codec]`. + This is what made the R6 boolean-interop defect quick to localize. + +### R6 — Report/fix related issues everywhere they appear + +Two distinct defects were found; both were fixed in **all** affected languages. + +1. **Compact-format boolean interop (found during this work, fixed in-repo).** + - **Failure.** JS/Rust wrote `(bool true)`; Python/C# wrote `(bool True)`. Each + decoder accepted only its own spelling, so a compact document written by one + language decoded to the wrong boolean in another (e.g. Python reading + `(bool true)` returned `False`). + - **Fix.** All encoders now write lowercase `true`/`false`; all decoders compare + case-insensitively. Regression tests added in every language + (`*compact_interop*`). +2. **`links-notation` parser bug (upstream, already fixed upstream).** + - A nested `(id: …)` definition is mis-parsed by the Python `links-notation` + parser (reproduced on **0.11.2**, the version Python pins). Details, minimal + reproduction, workaround and status are preserved in + [`data/links-notation-parser-bug.md`](./data/links-notation-parser-bug.md). + - **Why no upstream issue was filed:** the bug is **already fixed in + `links-notation` 0.14.0**, and this repository already works around it (the + compact encoder emits sibling `obj_N` definitions instead of nesting them, and + the readable default never uses `(id: …)` at all). The recommended follow-up — + bumping the Python/JS pins to 0.14.0 — is a separate dependency upgrade, noted + but deliberately kept out of this format-focused PR. + +### R7 — Single PR + +- All work landed on `issue-39-e53c893293ed` / PR #40 as atomic commits. + +--- + +## 4. Template file-tree comparison (R3) + +The four templates are **single-language** repos; this project is a **monorepo** +that consolidates each language's CI + release into one workflow +(`.github/workflows/{js,rust,python,csharp}.yml`) plus the new `parity.yml`. + +| Template workflow | Present in every template | In this repo? | Decision | +|---|---|---|---| +| `links.yml` (link checker) | yes | no | Documented as a recommended follow-up (risk of failing on pre-existing links). | +| `security.yml` (CodeQL + dependency-review) | yes | no | Documented as a recommended follow-up; needs repo code-scanning settings. | +| `release.yml` | yes | folded into each language workflow | No change — this repo's layout differs by design. | +| `docs.yml` | python, csharp | no | Out of scope. | + +**Hygiene adopted now** (from the templates' `security.yml`): least-privilege +top-level `permissions`, `timeout-minutes`, and `concurrency` groups on the parity +workflow. + +**Recommended `security.yml` (adapted for the monorepo).** A CodeQL matrix over +`javascript-typescript`, `python`, `csharp` and `actions` plus +`dependency-review-action` on PRs, with `permissions: contents: read` at the top +and `security-events: write` only on the CodeQL job. It is not merged here because +enabling code scanning is a repository-settings action that must accompany the +workflow; merging the workflow alone can produce red CI on the first run. + +--- + +## 5. Online research and existing components reused (R4) + +Rather than build machinery from scratch, the solution reuses established +components; the online research below confirmed the idiomatic choice in each +language: + +- **Release automation.** [Changesets](https://github.com/changesets/changesets) + for JS and C#; [scriv](https://scriv.readthedocs.io/) fragments for Python; the + repo's own `changelog.d` fragment convention for Rust. This PR adds one release + fragment per language so the merge produces coordinated version bumps without any + hand-edited version string (hand-edits are actively blocked by + `rust/scripts/check-version-modification.mjs`). +- **JSON handling in the conformance harness.** `serde_json` (Rust), + `System.Text.Json` (C#), and the built-in `json` (Python) / `JSON` (JS) — no new + parser was written; the fixtures are ordinary JSON with a small tagged encoding + so key order and number types survive. +- **Base64.** Each language's standard/base library (`base64` crate, `Convert` + in .NET, `base64` in Python, `Buffer`/`btoa` in JS) for the `(base64 "…")` marker. +- **Security scanning.** [CodeQL](https://codeql.github.com/) and + [dependency-review-action](https://github.com/actions/dependency-review-action), + exactly as the templates use them. +- **The Links Notation parser** itself — [`links-notation`](https://github.com/link-foundation/links-notation) + — underpins the compact format; the readable format is a plain indented tree and + does not depend on the parser's `(id: …)` self-reference feature. + +--- + +## 6. Verification summary + +At the time of writing, all four language suites are green with the changes: + +- **Python:** `ruff check` / `ruff format --check` clean, `mypy` clean, 162 tests. +- **JavaScript:** `npm run check` clean, 244 tests. +- **Rust:** `cargo fmt --check` / `clippy` clean, 78 tests + 7 doctests, example + runs; the parity and changelog scripts have their own passing unit tests. +- **C#:** `dotnet format --verify-no-changes` clean, `build /warnaserror` clean, + 171 tests. +- **Cross-language:** the 39 shared readable-format fixtures pass unchanged in all + four languages; the parity gate reports this branch as balanced.