From b967586b4d0f31e3176d81db7e6efe1c98c5f33e Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 10 May 2026 12:15:37 +0200 Subject: [PATCH 1/4] rdf/turtle: emit a space before `;` and `.` terminators (#419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the de-facto Turtle style used in W3C 1.1 spec examples, Apache Jena's RIOT writer, and most hand-authored Turtle in the Solid / linked-data ecosystem: a space between the previous token and the statement terminator. Before: foaf:name "Alice"; foaf:age 30. After: foaf:name "Alice" ; foaf:age 30 . n3.js's writer hardcodes the no-space form (`;\n next`) and exposes no config knob. Approach: a literal-aware post-pass on the writer's output. The naïve `\S;\n` → `\S ;\n` regex is unsafe — string literals (especially triple-quoted) can contain `;\n` or `.\n` internally, and inserting a space inside a literal would silently CHANGE the literal's value. So: 1. Stash every string literal AND every into placeholders using a multi-character non-token-boundary sentinel bracketed by NULs (n3.js escapes raw NUL inside literals and never emits one in real Turtle output, so the sentinel can't collide with real content). 2. Apply the spacing regex to the redacted output. Now `;`/`.` only appear as actual statement terminators because all literal/IRI internals are hidden. 3. Restore placeholders. Stash order matters — triple-quoted before single-quoted, else `"""` looks like an empty `""` followed by `"` to the single-quoted regex. Same for triple-vs-single apostrophe. Tests: - "emits a space before ; and . terminators": positive - "does NOT add a space inside literals containing ; or .": safety regression — the post-pass MUST NOT corrupt literal values like `"foo;bar"`, `"has.dot"`, `"a;b.c"`. - "does NOT add a space inside an IRI containing ;": safety regression for IRIs like ``. Test count: 770 → 777 in full suite (+7: 3 #419 tests + 4 #416/#417/#415 follow-ups since main). --- src/rdf/turtle.js | Bin 17093 -> 19496 bytes test/turtle.test.js | 54 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/rdf/turtle.js b/src/rdf/turtle.js index b0ee59cf3ffbcfa1565dfaa640f905e05754956c..991cdde12590f8529a337c83ac15375df2b32371 100644 GIT binary patch delta 2447 zcmZ`*O>-MX5VddMJ2wuEaY`#;t#HC6cI;Ft;X@SV6qcz}AvtKIZAlxiW|o;*$*Lr^ zT;K-)2Yv!as`4B7Bl#t~o|P=w2|l=1yFJ~nU%%J8|NgrD_g~xp+}SQ0jU8%GTRXO% z6uDdtnFj3V%$od!DYraJP2tF!OWaaAL0JE&PsUO|?#pTCmFFpIPoC{G)ym_U^29o6 zno%=LHPhQ){tW3tfrLiJPHCPdC1(psQ8-R0)%5J%V>&Oa&zKJ41Hj=S@s*n98M{zB zwH3HU#!pCD4v3mVqoo0*y%ewT?$e;C&Th7tRwWZ|j?~Z_!pRwkN{yCo z{iF==J0?pLY7v;auZV}nEgX0aZKqQ-J_84qRiXxImik4E;!?jY>m-!{`R|EQ}}_BHtIVoSm41eBDS zI?kj?u;eid$V?Cgq2Xe`Vj@TtsU*Wnu$Wt#@VaxFn<7idr5R|!Xdi#`==AF^Lnljs zH7Dn1rOFDXC@?-W))u*NSYJ`w@fOk^FAgP`AW70l25-58sg!L1@l`N5+M+&=rG9ZJ;MZD$iM2kqc&@ zRX~tq>5`lCs6crseNQh`!1<?bwIG?vns}P~g*@qFcV(HGS1YKG-`>di!0a+>Wrg6YIwYf}EDLXiWagSx zd9YZ*o7UtLUN!RR7YFy{y0tSZ^e`+6aGfpAuQzL_8einYnyk~y9intZkzeEt0;=2e z$?-AODuLGM)vM+07=9RIlO5yf=XK1yL^!M$QXL@?oQDm#$uJ#L_hS7h@QZuVv`)t- zp*Y=$kMqJ!@KlZvDue?4t-Y6Pzfv`IoULhwK%gkfGJ0^BWtcYRb_l zwSXFFqnl$3zby7KbC@#6v^3AZ-h_=_!v>QM`m?e<)+bJ2K+%htHuuM|_i6<4d( zR&ls&jcs`F+_2PO1 zpl%dfW3PqC+I8f6U}5jL+buunK0mo=oUGFd(P$yO^GomzJ&EgrFJSbRnEck;I}&w= z7wYnLTBYD=>K*lGvP7|}g=)=Lez_~c?YO7ItfQPNE;v$Gc3ZVhRhlt82E_r6Ra#OQ|xNI7h(P7^o59bD=rJ} zRrP+9g4gAiTSg8pg}Z@vE9xbm)wkqa^wU12LI0bg?w%IY0oy38UjYB>H@n-d4?g}M U^ICRA_Q&^kK6>-w!|wh60L1_xJ^%m! delta 26 icmZ26gYjr9;|5j6%>`UK9Bf6Y#icnVn|BH2SpoopTM3T< diff --git a/test/turtle.test.js b/test/turtle.test.js index efbf99f1..f09b7202 100644 --- a/test/turtle.test.js +++ b/test/turtle.test.js @@ -153,6 +153,60 @@ describe('turtle converter — unit (#320 follow-ups)', () => { `node n3 should appear:\n${content}`); }); + it('emits a space before `;` and `.` terminators (#419)', async () => { + // The de-facto convention in W3C spec examples and Apache Jena + // RIOT is to separate the statement-terminator from the previous + // token by a space. n3.js packs them; JSS post-processes the + // output to add the space. + const doc = { + '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' }, + '@id': 'https://example.test/alice', + 'foaf:name': 'Alice', + 'foaf:age': 30, + }; + const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true); + // Every `;` must be preceded by whitespace (space or newline). + // Same for `.` at end-of-statement. + const offendingSemi = /[^\s];/.test(content); + const offendingDot = /[^\s]\.\s*$/m.test(content) || /[^\s]\.\n/.test(content); + assert.ok(!offendingSemi, `every ; must be preceded by whitespace, got:\n${content}`); + assert.ok(!offendingDot, `every . at line/doc end must be preceded by whitespace, got:\n${content}`); + }); + + it('does NOT add a space inside literals containing `;` or `.` (#419 safety)', async () => { + // Critical correctness test: the post-pass must NOT corrupt + // literal values. A literal "foo;bar" with the post-pass naïvely + // applied would become "foo ;bar" — silent data corruption. + const doc = { + '@context': { 'ex': 'https://example.test/ns#' }, + '@id': 'https://example.test/s', + 'ex:semicolonInside': 'foo;bar', + 'ex:dotInside': 'has.dot', + 'ex:both': 'a;b.c', + }; + const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true); + // The literals must round-trip verbatim — no inserted space. + assert.ok(content.includes('"foo;bar"'), + `literal "foo;bar" must survive verbatim, got:\n${content}`); + assert.ok(content.includes('"has.dot"'), + `literal "has.dot" must survive verbatim, got:\n${content}`); + assert.ok(content.includes('"a;b.c"'), + `literal "a;b.c" must survive verbatim, got:\n${content}`); + }); + + it('does NOT add a space inside an IRI containing `;` (#419 safety)', async () => { + // An IRI's content is bracketed by `<...>` — the post-pass + // shouldn't touch what's inside. + const doc = { + '@context': { 'ex': 'https://example.test/ns#' }, + '@id': 'https://example.test/s', + 'ex:rel': { '@id': 'https://example.test/path;with;semis' }, + }; + const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true); + assert.ok(content.includes(''), + `IRI must survive verbatim with internal ;, got:\n${content}`); + }); + it('cyclical nested node reference does not hang', async () => { // Two nested nodes reference each other. BFS must not loop. const a = { '@id': 'https://example.test/a', 'ex:knows': null }; From 41423cbf39d8b9e2ac4f80637a34685aa0e634cc Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 10 May 2026 12:23:32 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Address=20copilot=20pass=201=20on=20#420=20?= =?UTF-8?q?=E2=80=94=20assert=20by=20value,=20not=20by=20lexical=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two safety regression tests for #419 hardcoded that N3 serializes string literals with double quotes (`"foo;bar"`) and IRIs as `<...>`. That made them sensitive to N3 writer formatting choices — single quotes, long-string `"""..."""`, IRI escaping, etc. — even when the underlying literal value would still be preserved correctly. Fix: parse the emitted Turtle back to quads and assert the literal/IRI VALUES, not their lexical form. The properties the tests actually want to enforce are: - "foo;bar" round-trips as a literal whose value is foo;bar (no inserted space) - round-trips as a NamedNode with that exact IRI Both are now assertion-by-value. Resilient against any future N3 upgrade that changes quote style, IRI escaping, or the long- string threshold. Test count: same 777. --- test/turtle.test.js | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/test/turtle.test.js b/test/turtle.test.js index f09b7202..d977d092 100644 --- a/test/turtle.test.js +++ b/test/turtle.test.js @@ -177,6 +177,10 @@ describe('turtle converter — unit (#320 follow-ups)', () => { // Critical correctness test: the post-pass must NOT corrupt // literal values. A literal "foo;bar" with the post-pass naïvely // applied would become "foo ;bar" — silent data corruption. + // + // Assert by VALUE, not by lexical form. A quote-agnostic parser + // round-trip survives any future N3 writer style change + // (single vs double quotes, long-string `"""..."""`, etc.). const doc = { '@context': { 'ex': 'https://example.test/ns#' }, '@id': 'https://example.test/s', @@ -185,26 +189,42 @@ describe('turtle converter — unit (#320 follow-ups)', () => { 'ex:both': 'a;b.c', }; const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true); - // The literals must round-trip verbatim — no inserted space. - assert.ok(content.includes('"foo;bar"'), - `literal "foo;bar" must survive verbatim, got:\n${content}`); - assert.ok(content.includes('"has.dot"'), - `literal "has.dot" must survive verbatim, got:\n${content}`); - assert.ok(content.includes('"a;b.c"'), - `literal "a;b.c" must survive verbatim, got:\n${content}`); + // Round-trip: parse the emitted Turtle, walk the quads, assert + // the literal values came back exactly as authored. + const { Parser } = await import('n3'); + const parser = new Parser({ baseIRI: 'https://example.test/' }); + const quads = parser.parse(content); + const objectsByPredicate = new Map(); + for (const q of quads) { + if (q.object.termType !== 'Literal') continue; + objectsByPredicate.set(q.predicate.value, q.object.value); + } + assert.strictEqual(objectsByPredicate.get('https://example.test/ns#semicolonInside'), 'foo;bar', + `literal value for ex:semicolonInside must be "foo;bar"`); + assert.strictEqual(objectsByPredicate.get('https://example.test/ns#dotInside'), 'has.dot', + `literal value for ex:dotInside must be "has.dot"`); + assert.strictEqual(objectsByPredicate.get('https://example.test/ns#both'), 'a;b.c', + `literal value for ex:both must be "a;b.c"`); }); it('does NOT add a space inside an IRI containing `;` (#419 safety)', async () => { // An IRI's content is bracketed by `<...>` — the post-pass - // shouldn't touch what's inside. + // shouldn't touch what's inside. Assert by value via parser + // round-trip so we don't depend on N3 writer formatting. const doc = { '@context': { 'ex': 'https://example.test/ns#' }, '@id': 'https://example.test/s', 'ex:rel': { '@id': 'https://example.test/path;with;semis' }, }; const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true); - assert.ok(content.includes(''), - `IRI must survive verbatim with internal ;, got:\n${content}`); + const { Parser } = await import('n3'); + const parser = new Parser({ baseIRI: 'https://example.test/' }); + const quads = parser.parse(content); + const rels = quads + .filter(q => q.predicate.value === 'https://example.test/ns#rel') + .map(q => q.object.value); + assert.deepStrictEqual(rels, ['https://example.test/path;with;semis'], + `IRI value must round-trip with internal ; intact`); }); it('cyclical nested node reference does not hang', async () => { From aced2eaabe7d1b6c20423281080e0c9ec4934bdd Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 10 May 2026 12:30:23 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Address=20copilot=20pass=202=20on=20#420=20?= =?UTF-8?q?=E2=80=94=20pin=20terminator=20presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "emits a space before ; and ." test asserted no offending terminators (without preceding whitespace) but didn't assert the terminators were actually present. If a future N3 upgrade ever switched to the "one triple per statement, no ; continuations" output style, the negative assertions would pass vacuously and the test would stop exercising the spacing behavior at all. Fix: pin the presence of at least one ` ;` and one ` .` (with preceding whitespace) before checking for offending forms. Now the test fails loudly if either disappears. Test count: same 777. --- test/turtle.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/turtle.test.js b/test/turtle.test.js index d977d092..7d336531 100644 --- a/test/turtle.test.js +++ b/test/turtle.test.js @@ -158,6 +158,13 @@ describe('turtle converter — unit (#320 follow-ups)', () => { // RIOT is to separate the statement-terminator from the previous // token by a space. n3.js packs them; JSS post-processes the // output to add the space. + // + // Use TWO predicates on the same subject so n3.js emits a `;` + // continuation (multiple triples on one subject). If a future + // N3 upgrade ever switched to "one triple per statement" style + // and dropped `;` entirely, this test would otherwise pass + // vacuously. The presence-of-terminator assertions below pin + // that behavior. const doc = { '@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' }, '@id': 'https://example.test/alice', @@ -165,6 +172,10 @@ describe('turtle converter — unit (#320 follow-ups)', () => { 'foaf:age': 30, }; const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true); + // Pin "at least one ; and one . exists" — otherwise an absent + // terminator would let the negative assertions pass vacuously. + assert.match(content, /\s;/, `output must contain at least one ; terminator, got:\n${content}`); + assert.match(content, /\s\.(?:\s|$)/, `output must contain at least one . terminator, got:\n${content}`); // Every `;` must be preceded by whitespace (space or newline). // Same for `.` at end-of-statement. const offendingSemi = /[^\s];/.test(content); From 8a336990dcc5f23736b5d9d36f1c3ab17325c4e8 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 10 May 2026 12:38:16 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Address=20copilot=20pass=203=20on=20#420=20?= =?UTF-8?q?=E2=80=94=20require=20literal=20space,=20not=20any=20whitespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pass-2 assertions used `\s` / `[\s]` patterns, which would treat `\n;`, `\t;`, etc. as acceptable too. The #419 intent is specifically a single SPACE before the terminator (matching W3C Turtle 1.1 examples and Apache Jena's RIOT writer): `value ;` / `value .` If a future N3 upgrade ever emitted `\n;` or `\t;`, the pass-2 test would have passed despite the visually-different output. Fix: tighten the assertions to require a literal ` ;` / ` .` (a single space). Both presence-checks and the offending-form negative checks use ` ` (space) explicitly instead of `\s`. Test count: same 777. --- test/turtle.test.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/test/turtle.test.js b/test/turtle.test.js index 7d336531..2883ed56 100644 --- a/test/turtle.test.js +++ b/test/turtle.test.js @@ -172,16 +172,20 @@ describe('turtle converter — unit (#320 follow-ups)', () => { 'foaf:age': 30, }; const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true); - // Pin "at least one ; and one . exists" — otherwise an absent - // terminator would let the negative assertions pass vacuously. - assert.match(content, /\s;/, `output must contain at least one ; terminator, got:\n${content}`); - assert.match(content, /\s\.(?:\s|$)/, `output must contain at least one . terminator, got:\n${content}`); - // Every `;` must be preceded by whitespace (space or newline). - // Same for `.` at end-of-statement. - const offendingSemi = /[^\s];/.test(content); - const offendingDot = /[^\s]\.\s*$/m.test(content) || /[^\s]\.\n/.test(content); - assert.ok(!offendingSemi, `every ; must be preceded by whitespace, got:\n${content}`); - assert.ok(!offendingDot, `every . at line/doc end must be preceded by whitespace, got:\n${content}`); + // Pin "at least one ` ;` and one ` .` exists" with a literal + // SPACE — not just any whitespace. The intended output style + // (matching W3C Turtle 1.1 spec examples) is a single space + // separator: `value ;` / `value .`. Allowing `\n;` or `\t;` + // would let the test pass on visually-different output. + assert.match(content, / ;/, `output must contain at least one " ;" terminator (space-prefixed), got:\n${content}`); + assert.match(content, / \.(?:\s|$)/, `output must contain at least one " ." terminator (space-prefixed), got:\n${content}`); + // Every `;` must be preceded by a literal space. Same for `.` + // at end-of-statement. Reject anything else (newline, tab, + // packed-against-token). + const offendingSemi = /[^ ];/.test(content); + const offendingDot = /[^ ]\.\s*$/m.test(content) || /[^ ]\.\n/.test(content); + assert.ok(!offendingSemi, `every ; must be preceded by a single space, got:\n${content}`); + assert.ok(!offendingDot, `every . at line/doc end must be preceded by a single space, got:\n${content}`); }); it('does NOT add a space inside literals containing `;` or `.` (#419 safety)', async () => {