Skip to content

Commit b4794ce

Browse files
Reject extra row values, formalize inline comments, harden quoting; isonantic-ts parse(); fix all Dependabot alerts
Parser strictness (all implementations: py, js, ts, go, rust, cpp, n8n inline parser), continuing the integrity work: - Rows (ISON and ISONL) with more values than declared fields now raise a syntax error with the line number instead of silently truncating; missing values still pad null - Inline comments formalized: an unquoted token starting with '#' in a data row or ISONL values section begins a comment; quoted tokens are always data. Previously inline comments only "worked" as a side effect of the silent-truncation bug - Regular ISON serializer now quotes strings containing \r or backslashes, starting with '#' (a leading-'#' value used to turn its row into a comment on re-parse), shaped like a kind.name block header (a single-field value like 'a.true' used to be re-parsed as a new block header), and empty strings (ts/rust dropped the column) - ISONL serializers quote leading-'#' values - Additional parity fixes surfaced by the shared property test: quoted tokens stay strings in the regular go/ts/rust/n8n parsers; ts/rust header detection tightened to exact kind.name identifiers; go pads missing fields explicitly; n8n keeps empty quoted values - New tests in every implementation: extra-value rejection, inline comment semantics, and a seeded 300-trial regular-format round-trip property test (twin of the ISONL one) isonantic-ts 1.1.0: - New parse()/parseSafe() that delegate all text parsing to ison-ts (docs advertised parse() but it never existed); references mapped, rows validated through the schema with per-field errors - VERSION constant aligned with package.json (was drifted at 1.0.0) Dependency security - all 39 Dependabot alerts resolved (0 remaining): - ison-vscode: npm audit fix + @vscode/vsce 2.x -> 3.9.2 - ison-ts / isonantic-ts: npm audit fix + vitest 1.x -> 3.2.6 - n8n-nodes-ison: npm audit fix + n8n-workflow 1.x -> 2.16 (dev) + gulp 4 -> 5; removed vestigial ison-parser runtime dependency (the node uses its inline parser) n8n-nodes-ison packaging fix: tsconfig rootDir corrected so the compiled node lands at dist/nodes/Ison/Ison.node.js where the n8n manifest points (it previously emitted to dist/Ison/ and the published package would not load) All test suites pass: ison-py (full), isonantic 45, ison-cli 27, ison-js 39+47, ison-ts 35, ison-go, ison-rust 15, ison-cpp 36 (MSVC), isonantic-ts 58, n8n harness 20/20 + 14/14. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0696cab commit b4794ce

29 files changed

Lines changed: 6233 additions & 6005 deletions

ison-cpp/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
- **ISONL Round-Trip Corruption**: Fixed quote-tracking desync in the ISONL section splitter — a quoted value ending in an escaped backslash (e.g. `"x \\"`) let a later `|` split the line in the wrong place. The splitter now consumes escape pairs instead of using look-behind.
77
- **ISONL Serialization of `\r` and `\\`**: The ISONL serializer now quotes strings containing carriage returns or backslashes (and bare numeric strings), so they survive a round-trip instead of being emitted raw.
88
- **Explicit `\|` Unescape**: The tokenizer now maps the `\|` escape to `|` explicitly.
9+
- **Extra Values Now Error**: A data row (regular ISON or ISONL) with more values than fields now throws `ISONSyntaxError` (`"Row has N values but only M fields (extra value: ...)"`) instead of silently truncating. Missing trailing values still pad with `null`.
10+
- **Regular Serializer Quoting**: `Serializer` now quotes strings containing `\r` or `\\`, strings starting with `#` (which would be re-parsed as a comment), and strings shaped like a `kind.name` block header (which, alone on a row line, would be re-parsed as the start of a new block), so they survive a round-trip.
11+
- **ISONL Serializer Quoting**: The ISONL serializer now also quotes strings starting with `#`, which would otherwise be re-parsed as an inline comment in the values section.
912

1013
### Added
1114
- **ISONL Envelope Validation**: `dumps_isonl` now throws `ISONError` for block kind/name or field names that cannot be serialized (empty, or containing pipe, quote, backslash, or whitespace; kind additionally must not contain `.` or start with `#`) instead of silently emitting corrupt lines. Dots in the block name remain legal — the parser splits `kind.name` on the first dot.
15+
- **Inline Comments Formalized**: In data rows (regular ISON) and ISONL values sections, an unquoted token starting with `#` begins an inline comment — it and everything after it on the line are ignored. Quoted tokens are always data, never comments.
1216

1317
## [1.0.1] - 2025-12-29
1418

ison-cpp/include/ison_parser.hpp

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,50 @@ class Parser {
579579
return doc;
580580
}
581581

582+
/**
583+
* Return the number of leading tokens that are data: an unquoted token
584+
* starting with '#' begins an inline comment, ignoring it and everything
585+
* after it. Quoted tokens are always data. (Shared with ISONLParser.)
586+
*/
587+
static size_t strip_inline_comment(const std::vector<std::string>& raw_tokens,
588+
const std::vector<bool>& quoted_flags) {
589+
for (size_t i = 0; i < raw_tokens.size(); ++i) {
590+
if (!quoted_flags[i] && !raw_tokens[i].empty() && raw_tokens[i][0] == '#') {
591+
return i;
592+
}
593+
}
594+
return raw_tokens.size();
595+
}
596+
597+
/**
598+
* Reject rows with more values than fields instead of silently
599+
* truncating them. (Shared with ISONLParser.)
600+
*/
601+
static void check_extra_tokens(const std::vector<std::string>& raw_tokens,
602+
size_t field_count, int line_num) {
603+
if (raw_tokens.size() <= field_count) return;
604+
throw ISONSyntaxError(
605+
"Row has " + std::to_string(raw_tokens.size()) + " values but only " +
606+
std::to_string(field_count) + " fields (extra value: '" +
607+
raw_tokens[field_count] + "')",
608+
line_num, 0);
609+
}
610+
611+
/**
612+
* Check whether a bare line scans as a 'kind.name' block header.
613+
* (Also used by Serializer to quote strings that would be misread
614+
* as a header when they land alone on a row line.)
615+
*/
616+
static bool looks_like_header(const std::string& line) {
617+
size_t dot_pos = line.find('.');
618+
if (dot_pos == std::string::npos) return false;
619+
if (line.find(' ') != std::string::npos) return false;
620+
621+
std::string kind = line.substr(0, dot_pos);
622+
std::string name = line.substr(dot_pos + 1);
623+
return is_valid_id(kind) && is_valid_id(name);
624+
}
625+
582626
private:
583627
std::vector<std::string> lines_;
584628
size_t line_num_;
@@ -666,16 +710,6 @@ class Parser {
666710
return block;
667711
}
668712

669-
bool looks_like_header(const std::string& line) const {
670-
size_t dot_pos = line.find('.');
671-
if (dot_pos == std::string::npos) return false;
672-
if (line.find(' ') != std::string::npos) return false;
673-
674-
std::string kind = line.substr(0, dot_pos);
675-
std::string name = line.substr(dot_pos + 1);
676-
return is_valid_id(kind) && is_valid_id(name);
677-
}
678-
679713
static bool is_valid_id(const std::string& s) {
680714
if (s.empty()) return false;
681715
if (!std::isalpha(static_cast<unsigned char>(s[0])) && s[0] != '_') return false;
@@ -691,6 +725,7 @@ class Parser {
691725
std::vector<std::string> raw_tokens = tokenizer.tokenize();
692726

693727
std::vector<Value> values;
728+
std::vector<bool> quoted_flags;
694729
size_t pos = 0;
695730

696731
for (size_t i = 0; i < raw_tokens.size(); ++i) {
@@ -699,6 +734,7 @@ class Parser {
699734
}
700735
bool was_quoted = pos < line.size() && line[pos] == '"';
701736
values.push_back(TypeInferrer::infer(raw_tokens[i], was_quoted));
737+
quoted_flags.push_back(was_quoted);
702738

703739
if (was_quoted) {
704740
++pos;
@@ -712,6 +748,11 @@ class Parser {
712748
}
713749
}
714750

751+
size_t keep = strip_inline_comment(raw_tokens, quoted_flags);
752+
raw_tokens.resize(keep);
753+
values.resize(keep);
754+
check_extra_tokens(raw_tokens, fields.size(), static_cast<int>(line_num_ + 1));
755+
715756
Row row;
716757
for (size_t i = 0; i < fields.size(); ++i) {
717758
if (i < values.size()) {
@@ -849,12 +890,17 @@ class Serializer {
849890
static std::string quote_if_needed(const std::string& s) {
850891
if (s.empty()) return "\"\"";
851892

852-
bool needs_quote = (s == "true" || s == "false" || s == "null" || s[0] == ':');
893+
// '\r' and '\\' would be emitted raw and corrupt on re-parse; a
894+
// leading '#' would turn the line into a comment (or an inline
895+
// comment) and silently lose data.
896+
bool needs_quote = (s == "true" || s == "false" || s == "null" ||
897+
s[0] == ':' || s[0] == '#');
853898

854899
if (!needs_quote) {
855900
for (size_t i = 0; i < s.size(); ++i) {
856901
char c = s[i];
857-
if (c == ' ' || c == '\t' || c == '"' || c == '\n' || c == '\r') {
902+
if (c == ' ' || c == '\t' || c == '"' || c == '\n' ||
903+
c == '\r' || c == '\\') {
858904
needs_quote = true;
859905
break;
860906
}
@@ -865,6 +911,12 @@ class Serializer {
865911
needs_quote = true;
866912
}
867913

914+
// A bare 'kind.name'-shaped value alone on a row line would be
915+
// misread as the next block header on re-parse
916+
if (!needs_quote && Parser::looks_like_header(s)) {
917+
needs_quote = true;
918+
}
919+
868920
if (needs_quote) {
869921
std::string escaped;
870922
for (size_t i = 0; i < s.size(); ++i) {
@@ -993,13 +1045,16 @@ class ISONLParser {
9931045
Tokenizer value_tokenizer(sections[2], line_num);
9941046
std::vector<std::string> raw_values = value_tokenizer.tokenize();
9951047

1048+
std::vector<Value> typed_values;
1049+
std::vector<bool> quoted_flags;
9961050
size_t pos = 0;
997-
for (size_t i = 0; i < record.fields.size() && i < raw_values.size(); ++i) {
1051+
for (size_t i = 0; i < raw_values.size(); ++i) {
9981052
while (pos < sections[2].size() && (sections[2][pos] == ' ' || sections[2][pos] == '\t')) {
9991053
++pos;
10001054
}
10011055
bool was_quoted = pos < sections[2].size() && sections[2][pos] == '"';
1002-
record.values[record.fields[i]] = TypeInferrer::infer(raw_values[i], was_quoted);
1056+
typed_values.push_back(TypeInferrer::infer(raw_values[i], was_quoted));
1057+
quoted_flags.push_back(was_quoted);
10031058

10041059
if (was_quoted) {
10051060
++pos;
@@ -1013,6 +1068,17 @@ class ISONLParser {
10131068
}
10141069
}
10151070

1071+
size_t keep = Parser::strip_inline_comment(raw_values, quoted_flags);
1072+
raw_values.resize(keep);
1073+
typed_values.resize(keep);
1074+
Parser::check_extra_tokens(raw_values, record.fields.size(), line_num);
1075+
1076+
for (size_t i = 0; i < record.fields.size(); ++i) {
1077+
record.values[record.fields[i]] = (i < typed_values.size())
1078+
? typed_values[i]
1079+
: Value(nullptr);
1080+
}
1081+
10161082
return record;
10171083
}
10181084

@@ -1206,8 +1272,11 @@ class ISONLSerializer {
12061272
static std::string quote_if_needed(const std::string& s) {
12071273
if (s.empty()) return "\"\"";
12081274

1275+
// A leading '#' would start an inline comment in the values section
1276+
// and silently drop the rest of the row on re-parse
12091277
bool needs_quote = (s == "true" || s == "false" || s == "null" ||
1210-
s[0] == ':' || s.find(' ') != std::string::npos ||
1278+
s[0] == ':' || s[0] == '#' ||
1279+
s.find(' ') != std::string::npos ||
12111280
s.find('\t') != std::string::npos ||
12121281
s.find('"') != std::string::npos ||
12131282
s.find('\n') != std::string::npos ||

ison-cpp/tests/test_ison_parser.cpp

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,158 @@ TEST(isonl_envelope_validation) {
589589
ASSERT_EQ(parsed.blocks[0].name, "v1.2");
590590
}
591591

592+
// =============================================================================
593+
// Row Integrity Tests
594+
// =============================================================================
595+
596+
TEST(extra_values_rejected) {
597+
// Regression: rows with more values than fields must error, not truncate
598+
bool threw = false;
599+
try {
600+
parse("table.t\na b\n1 2 3");
601+
} catch (const ISONSyntaxError& e) {
602+
threw = true;
603+
ASSERT(std::string(e.what()).find("3 values") != std::string::npos);
604+
}
605+
ASSERT(threw);
606+
607+
// A quoted token is data, never a comment
608+
threw = false;
609+
try {
610+
parse("table.t\na b\n1 2 \"#not-a-comment\"");
611+
} catch (const ISONSyntaxError&) {
612+
threw = true;
613+
}
614+
ASSERT(threw);
615+
616+
// ISONL
617+
threw = false;
618+
try {
619+
loads_isonl("table.t|a b|1 2 3");
620+
} catch (const ISONSyntaxError& e) {
621+
threw = true;
622+
ASSERT(std::string(e.what()).find("3 values") != std::string::npos);
623+
}
624+
ASSERT(threw);
625+
}
626+
627+
TEST(inline_trailing_comment) {
628+
// An unquoted token starting with '#' begins an inline comment
629+
auto doc = parse("table.t\na b\n1 2 # note ignored");
630+
ASSERT_EQ(as_int(doc["t"][0].at("a")), 1);
631+
ASSERT_EQ(as_int(doc["t"][0].at("b")), 2);
632+
633+
auto doc2 = loads_isonl("table.t|a b|1 2 # note ignored");
634+
ASSERT_EQ(as_int(doc2["t"][0].at("a")), 1);
635+
ASSERT_EQ(as_int(doc2["t"][0].at("b")), 2);
636+
637+
// Comment mid-row: remaining fields are missing (null), not data
638+
auto doc3 = parse("table.t\na b\n1 #tag");
639+
ASSERT_EQ(as_int(doc3["t"][0].at("a")), 1);
640+
ASSERT(is_null(doc3["t"][0].at("b")));
641+
642+
// Quoted tokens are always data, never comments
643+
auto doc4 = parse("table.t\na b\n1 \"#tag\"");
644+
ASSERT_EQ(as_int(doc4["t"][0].at("a")), 1);
645+
ASSERT_EQ(as_string(doc4["t"][0].at("b")), "#tag");
646+
647+
// Serializer quotes leading-'#' strings so they round-trip as data
648+
Document out_doc;
649+
Block block("table", "t");
650+
block.fields = {"a"};
651+
Row row;
652+
row["a"] = std::string("#tag");
653+
block.rows.push_back(row);
654+
out_doc.blocks.push_back(block);
655+
auto roundtrip = parse(dumps(out_doc));
656+
ASSERT_EQ(as_string(roundtrip["t"][0].at("a")), "#tag");
657+
}
658+
659+
TEST(ison_roundtrip_property) {
660+
// Explicit regression: header-shaped string values ('kind.name') must be
661+
// quoted by the serializer, or a single-field row line like 'a.true'
662+
// would be re-parsed as a NEW block header and split the block
663+
{
664+
Document doc;
665+
Block block("table", "t");
666+
block.fields = {"v"};
667+
Row r1;
668+
r1["v"] = std::string("a.true");
669+
Row r2;
670+
r2["v"] = std::string("object.config");
671+
block.rows.push_back(r1);
672+
block.rows.push_back(r2);
673+
doc.blocks.push_back(block);
674+
675+
auto parsed = parse(dumps(doc));
676+
ASSERT_EQ(parsed.size(), 1);
677+
ASSERT_EQ(parsed.blocks[0].rows.size(), 2);
678+
ASSERT_EQ(as_string(parsed.blocks[0].rows[0].at("v")), "a.true");
679+
ASSERT_EQ(as_string(parsed.blocks[0].rows[1].at("v")), "object.config");
680+
}
681+
682+
// Regular-format twin of isonl_roundtrip_property: random strings over a
683+
// hostile alphabet must round-trip through dumps/parse.
684+
// Deterministic LCG so failures are reproducible without <random>.
685+
const char* alphabet[] = {
686+
"a", "b", " ", "|", "\"", "\\", "\n", "\r", "\t",
687+
".", ":", "#", "0", "1", "true", "null"
688+
};
689+
const int alphabet_size = static_cast<int>(sizeof(alphabet) / sizeof(alphabet[0]));
690+
691+
uint64_t state = 20260713ULL;
692+
auto next_int = [&state](int lo, int hi) -> int {
693+
state = (state * 1103515245ULL + 12345ULL) % 2147483648ULL;
694+
return lo + static_cast<int>(state % static_cast<uint64_t>(hi - lo + 1));
695+
};
696+
697+
for (int trial = 0; trial < 300; ++trial) {
698+
int num_fields = next_int(1, 4);
699+
std::vector<std::string> fields;
700+
for (int i = 0; i < num_fields; ++i) {
701+
fields.push_back("f" + std::to_string(i));
702+
}
703+
704+
int num_rows = next_int(1, 3);
705+
std::vector<Row> rows;
706+
for (int r = 0; r < num_rows; ++r) {
707+
Row row;
708+
for (int f = 0; f < num_fields; ++f) {
709+
int len = next_int(0, 12);
710+
std::string value;
711+
for (int k = 0; k < len; ++k) {
712+
value += alphabet[next_int(0, alphabet_size - 1)];
713+
}
714+
row[fields[f]] = value;
715+
}
716+
rows.push_back(row);
717+
}
718+
719+
Document doc;
720+
Block block("table", "t");
721+
block.fields = fields;
722+
block.rows = rows;
723+
doc.blocks.push_back(block);
724+
725+
std::string out = dumps(doc);
726+
auto parsed = parse(out);
727+
728+
ASSERT_EQ(parsed.size(), 1);
729+
ASSERT_EQ(parsed.blocks[0].rows.size(), rows.size());
730+
for (size_t r = 0; r < rows.size(); ++r) {
731+
for (size_t f = 0; f < fields.size(); ++f) {
732+
const Value& got = parsed.blocks[0].rows[r].at(fields[f]);
733+
const std::string& expected = as_string(rows[r].at(fields[f]));
734+
if (!is_string(got) || as_string(got) != expected) {
735+
throw std::runtime_error(
736+
"trial " + std::to_string(trial) + " row " + std::to_string(r) +
737+
" field " + fields[f] + ": round-trip mismatch, doc: " + out);
738+
}
739+
}
740+
}
741+
}
742+
}
743+
592744
// =============================================================================
593745
// JSON Conversion Tests
594746
// =============================================================================
@@ -758,6 +910,11 @@ int main() {
758910
RUN_TEST(isonl_roundtrip_property);
759911
RUN_TEST(isonl_envelope_validation);
760912

913+
// Row integrity
914+
RUN_TEST(extra_values_rejected);
915+
RUN_TEST(inline_trailing_comment);
916+
RUN_TEST(ison_roundtrip_property);
917+
761918
// JSON
762919
RUN_TEST(to_json);
763920

0 commit comments

Comments
 (0)