Repository: eemeli/yaml
Pull request #611 (2025-03-18 21:20:48)

feat: Add `--merge` option to CLI tool
~~~ yaml
# test.yaml
a: &a
  a: 2
b:
  <<: *a
~~~
~~~ sh
yaml --single --merge --json < test.yaml
~~~
~~~ json
{
  "a": {
    "a": 2
  },
  "b": {
    "a": 2
  }
}
~~~



Patch:
diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
index 0b5f657..1dfa952 100644
--- a/.github/workflows/nodejs.yml
+++ b/.github/workflows/nodejs.yml
@@ -10,17 +10,14 @@ on:
 jobs:
   test:
     runs-on: ${{ matrix.os }}
-
     strategy:
       matrix:
         os: [ubuntu-latest, windows-latest, macos-latest]
         node-version: [18, 20, latest]
-
     steps:
       - uses: actions/checkout@v4
         with: { submodules: true }
-      - name: Use Node.js ${{ matrix.node-version }}
-        uses: actions/setup-node@v4
+      - uses: actions/setup-node@v4
         with:
           node-version: ${{ matrix.node-version }}
       - run: npm ci
@@ -37,3 +34,60 @@ jobs:
         with: { node-version: 20 }
       - run: npm ci
       - run: npm run lint
+
+  node-16:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: ['16.0.0', '16']
+    steps:
+      - uses: actions/checkout@v4
+        with: { submodules: true }
+      - uses: actions/setup-node@v4
+        with:
+          node-version: ${{ matrix.node-version }}
+      - run: npm ci
+      - run: npm install --no-save rollup@3
+      - run: npm test
+      - run: npm run test:dist
+
+  node-14-build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: ['14.18.0']
+    steps:
+      - uses: actions/checkout@v4
+        with: { submodules: true }
+      - uses: actions/setup-node@v4
+        with:
+          node-version: ${{ matrix.node-version }}
+      - run: npm install --global npm@7
+      - run: npm ci
+      - run: npm install --no-save rollup@2
+      - run: npm run test:dist
+      - uses: actions/upload-artifact@v4
+        with:
+          name: yaml-node14
+          path: dist/
+  node-14-run:
+    needs: node-14-build
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: ['14.6.0', '14', '15.0.0']
+    steps:
+      - uses: actions/checkout@v4
+        with: { submodules: true }
+      - uses: actions/setup-node@v4
+        with:
+          node-version: ${{ matrix.node-version }}
+      - run: npm install --global npm@7
+      - run: npm ci
+      - uses: actions/download-artifact@v4
+        with:
+          name: yaml-node14
+          path: dist/
+      - run: npx jest --config config/jest.config.js
+        env:
+          TEST: dist
diff --git a/config/jest.config.js b/config/jest.config.js
index 57f878c..dde244d 100644
--- a/config/jest.config.js
+++ b/config/jest.config.js
@@ -19,28 +19,27 @@ const transform = {
   '[/\\\\]tests[/\\\\].*\\.(m?js|ts)$': babel
 }
 
-// The npm script name is significant.
-switch (process.env.npm_lifecycle_event) {
-  case 'test:dist':
-    console.log('Testing build output from dist/')
-    moduleNameMapper = {
-      '^yaml$': '<rootDir>/dist/index.js',
-      '^yaml/cli$': '<rootDir>/dist/cli.mjs',
-      '^yaml/util$': '<rootDir>/dist/util.js',
-      '^../src/test-events.ts$': '<rootDir>/dist/test-events.js'
-    }
-    transform['[/\\\\]dist[/\\\\].*\\.mjs$'] = babel
-    break
-
-  case 'test':
-  default:
-    process.env.TRACE_LEVEL = 'log'
-    moduleNameMapper = {
-      '^yaml$': '<rootDir>/src/index.ts',
-      '^yaml/cli$': '<rootDir>/src/cli.ts',
-      '^yaml/util$': '<rootDir>/src/util.ts'
-    }
-    transform['[/\\\\]src[/\\\\].*\\.ts$'] = babel
+// npm_lifecycle_event is the npm script name.
+if (
+  process.env.TEST === 'dist' ||
+  process.env.npm_lifecycle_event === 'test:dist'
+) {
+  console.log('Testing build output from dist/')
+  moduleNameMapper = {
+    '^yaml$': '<rootDir>/dist/index.js',
+    '^yaml/cli$': '<rootDir>/dist/cli.mjs',
+    '^yaml/util$': '<rootDir>/dist/util.js',
+    '^../src/test-events.ts$': '<rootDir>/dist/test-events.js'
+  }
+  transform['[/\\\\]dist[/\\\\].*\\.mjs$'] = babel
+} else {
+  process.env.TRACE_LEVEL = 'log'
+  moduleNameMapper = {
+    '^yaml$': '<rootDir>/src/index.ts',
+    '^yaml/cli$': '<rootDir>/src/cli.ts',
+    '^yaml/util$': '<rootDir>/src/util.ts'
+  }
+  transform['[/\\\\]src[/\\\\].*\\.ts$'] = babel
 }
 
 module.exports = {
diff --git a/config/rollup.node-config.mjs b/config/rollup.node-config.mjs
index e245ef5..059bc77 100644
--- a/config/rollup.node-config.mjs
+++ b/config/rollup.node-config.mjs
@@ -49,6 +49,14 @@ function fixDynamicImportRewrite(context) {
   }
 }
 
+/**
+ * Leave out `node:` prefix as unsupported in Node.js < 14.18
+ *
+ * @param {string} id
+ */
+const fixNodePaths = id =>
+  id.startsWith('node:') ? id.substring(5) : undefined
+
 export default [
   {
     input: {
@@ -60,7 +68,8 @@ export default [
       dir: 'dist',
       format: 'cjs',
       esModule: false,
-      preserveModules: true
+      preserveModules: true,
+      paths: fixNodePaths
     },
     external: ['node:buffer', 'node:process'],
     plugins: [
@@ -72,7 +81,7 @@ export default [
   },
   {
     input: 'src/cli.ts',
-    output: { file: 'dist/cli.mjs' },
+    output: { file: 'dist/cli.mjs', paths: fixNodePaths },
     external: () => true,
     plugins: [
       typescript({
diff --git a/docs-slate b/docs-slate
index 54f4785..c1295b6 160000
--- a/docs-slate
+++ b/docs-slate
@@ -1,1 +1,1 @@
-Subproject commit 54f47854a2f38e08f3ecc0cc189dee00a037dd9f
+Subproject commit c1295b661bcac098b6da9587828aa403ade08218
diff --git a/docs/09_cli.md b/docs/09_cli.md
index 0788bcb..2edbf6e 100644
--- a/docs/09_cli.md
+++ b/docs/09_cli.md
@@ -17,6 +17,7 @@ Options:
   --help, -h    Show this message.
   --json, -j    Output JSON.
   --indent 2    Output pretty-printed data, indented by the given number of spaces.
+  --merge, -m   Enable support for "<<" merge keys.
 
 Additional options for bare "yaml" command:
   --doc, -d     Output pretty-printed JS Document objects.
diff --git a/jsr.jsonc b/jsr.jsonc
index f94259c..76f9eb8 100644
--- a/jsr.jsonc
+++ b/jsr.jsonc
@@ -1,6 +1,6 @@
 {
   "name": "@eemeli/yaml",
-  "version": "2.7.0",
+  "version": "2.7.1",
   "license": "ISC",
   "exports": {
     ".": "./src/index.ts",
diff --git a/package-lock.json b/package-lock.json
index 7f5d85e..7acb573 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "yaml",
-  "version": "2.7.0",
+  "version": "2.7.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "yaml",
-      "version": "2.7.0",
+      "version": "2.7.1",
       "license": "ISC",
       "bin": {
         "yaml": "bin.mjs"
@@ -35,7 +35,7 @@
         "typescript-eslint": "^8.4.0"
       },
       "engines": {
-        "node": ">= 14"
+        "node": ">= 14.6"
       }
     },
     "node_modules/@ampproject/remapping": {
diff --git a/package.json b/package.json
index b7fbff4..89ec026 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "yaml",
-  "version": "2.7.0",
+  "version": "2.7.1",
   "license": "ISC",
   "author": "Eemeli Aro <eemeli@gmail.com>",
   "repository": "github:eemeli/yaml",
@@ -91,6 +91,6 @@
     "typescript-eslint": "^8.4.0"
   },
   "engines": {
-    "node": ">= 14"
+    "node": ">= 14.6"
   }
 }
diff --git a/src/cli.ts b/src/cli.ts
index 9e80f32..b46a90c 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -27,6 +27,7 @@ Options:
   --help, -h    Show this message.
   --json, -j    Output JSON.
   --indent 2    Output pretty-printed data, indented by the given number of spaces.
+  --merge, -m   Enable support for "<<" merge keys.
 
 Additional options for bare "yaml" command:
   --doc, -d     Output pretty-printed JS Document objects.
@@ -59,6 +60,7 @@ export async function cli(
         doc: { type: 'boolean', short: 'd' },
         help: { type: 'boolean', short: 'h' },
         indent: { type: 'string', short: 'i' },
+        merge: { type: 'boolean', short: 'm' },
         json: { type: 'boolean', short: 'j' },
         single: { type: 'boolean', short: '1' },
         strict: { type: 'boolean', short: 's' },
@@ -127,7 +129,7 @@ export async function cli(
       const lineCounter = new LineCounter()
       const parser = new Parser(lineCounter.addNewLine)
       // @ts-expect-error Version is validated at runtime
-      const composer = new Composer({ version: opt.yaml })
+      const composer = new Composer({ version: opt.yaml, merge: opt.merge })
       const visitor: visitor | null = opt.visit
         ? (await import(resolve(opt.visit))).default
         : null
diff --git a/src/compose/compose-collection.ts b/src/compose/compose-collection.ts
index 8245919..f622d8d 100644
--- a/src/compose/compose-collection.ts
+++ b/src/compose/compose-collection.ts
@@ -108,11 +108,11 @@ export function composeCollection(
       ctx.schema.tags.push(Object.assign({}, kt, { default: false }))
       tag = kt
     } else {
-      if (kt?.collection) {
+      if (kt) {
         onError(
           tagToken,
           'BAD_COLLECTION_TYPE',
-          `${kt.tag} used for ${expType} collection, but expects ${kt.collection}`,
+          `${kt.tag} used for ${expType} collection, but expects ${kt.collection ?? 'scalar'}`,
           true
         )
       } else {
diff --git a/src/nodes/Alias.ts b/src/nodes/Alias.ts
index bcc1d57..75e990b 100644
--- a/src/nodes/Alias.ts
+++ b/src/nodes/Alias.ts
@@ -3,7 +3,7 @@ import type { Document } from '../doc/Document.ts'
 import type { FlowScalar } from '../parse/cst.ts'
 import type { StringifyContext } from '../stringify/stringify.ts'
 import { visit } from '../visit.ts'
-import { ALIAS, isAlias, isCollection, isPair } from './identity.ts'
+import { ALIAS, hasAnchor, isAlias, isCollection, isPair } from './identity.ts'
 import type { Node, Range } from './Node.ts'
 import { NodeBase } from './Node.ts'
 import type { Scalar } from './Scalar.ts'
@@ -38,21 +38,36 @@ export class Alias extends NodeBase {
    * Resolve the value of this alias within `doc`, finding the last
    * instance of the `source` anchor before this node.
    */
-  resolve(doc: Document): Scalar | YAMLMap | YAMLSeq | undefined {
+  resolve(
+    doc: Document,
+    ctx?: ToJSContext
+  ): Scalar | YAMLMap | YAMLSeq | undefined {
+    let nodes: Node[]
+    if (ctx?.aliasResolveCache) {
+      nodes = ctx.aliasResolveCache
+    } else {
+      nodes = []
+      visit(doc, {
+        Node: (_key: unknown, node: Node) => {
+          if (isAlias(node) || hasAnchor(node)) nodes.push(node)
+        }
+      })
+      if (ctx) ctx.aliasResolveCache = nodes
+    }
+
     let found: Scalar | YAMLMap | YAMLSeq | undefined = undefined
-    visit(doc, {
-      Node: (_key: unknown, node: Node) => {
-        if (node === this) return visit.BREAK
-        if (node.anchor === this.source) found = node
-      }
-    })
+    for (const node of nodes) {
+      if (node === this) break
+      if (node.anchor === this.source) found = node
+    }
+
     return found
   }
 
   toJSON(_arg?: unknown, ctx?: ToJSContext): unknown {
     if (!ctx) return { source: this.source }
     const { anchors, doc, maxAliasCount } = ctx
-    const source = this.resolve(doc)
+    const source = this.resolve(doc, ctx)
     if (!source) {
       const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}`
       throw new ReferenceError(msg)
diff --git a/src/nodes/toJS.ts b/src/nodes/toJS.ts
index 5732668..2b9c6c1 100644
--- a/src/nodes/toJS.ts
+++ b/src/nodes/toJS.ts
@@ -10,6 +10,8 @@ export interface AnchorData {
 
 export interface ToJSContext {
   anchors: Map<Node, AnchorData>
+  /** Cached anchor and alias nodes in the order they occur in the document */
+  aliasResolveCache?: Node[]
   doc: Document<Node, boolean>
   keep: boolean
   mapAsMap: boolean
diff --git a/src/parse/parser.ts b/src/parse/parser.ts
index 2d19e82..39718af 100644
--- a/src/parse/parser.ts
+++ b/src/parse/parser.ts
@@ -675,7 +675,21 @@ export class Parser {
         default: {
           const bv = this.startBlockValue(map)
           if (bv) {
-            if (atMapIndent && bv.type !== 'block-seq') {
+            if (bv.type === 'block-seq') {
+              if (
+                !it.explicitKey &&
+                it.sep &&
+                !includesToken(it.sep, 'newline')
+              ) {
+                yield* this.pop({
+                  type: 'error',
+                  offset: this.offset,
+                  message: 'Unexpected block-seq-ind on same line with key',
+                  source: this.source
+                })
+                return
+              }
+            } else if (atMapIndent) {
               map.items.push({ start })
             }
             this.stack.push(bv)
diff --git a/src/schema/yaml-1.1/binary.ts b/src/schema/yaml-1.1/binary.ts
index f05de01..14e37f9 100644
--- a/src/schema/yaml-1.1/binary.ts
+++ b/src/schema/yaml-1.1/binary.ts
@@ -34,6 +34,7 @@ export const binary: ScalarTag = {
   },
 
   stringify({ comment, type, value }, ctx, onComment, onChompKeep) {
+    if (!value) return ''
     const buf = value as Uint8Array // checked earlier by binary.identify()
     let str: string
     if (typeof Buffer === 'function') {
diff --git a/src/schema/yaml-1.1/timestamp.ts b/src/schema/yaml-1.1/timestamp.ts
index b59d6c7..7a2ca54 100644
--- a/src/schema/yaml-1.1/timestamp.ts
+++ b/src/schema/yaml-1.1/timestamp.ts
@@ -116,5 +116,5 @@ export const timestamp: ScalarTag & { test: RegExp } = {
   },
 
   stringify: ({ value }) =>
-    (value as Date).toISOString().replace(/(T00:00:00)?\.000Z$/, '')
+    (value as Date)?.toISOString().replace(/(T00:00:00)?\.000Z$/, '') ?? ''
 }
diff --git a/src/stringify/stringify.ts b/src/stringify/stringify.ts
index e699f4c..3abfd88 100644
--- a/src/stringify/stringify.ts
+++ b/src/stringify/stringify.ts
@@ -109,7 +109,8 @@ function getTagObject(tags: Array<ScalarTag | CollectionTag>, item: Node) {
   }
 
   if (!tagObj) {
-    const name = (obj as any)?.constructor?.name ?? typeof obj
+    const name =
+      (obj as any)?.constructor?.name ?? (obj === null ? 'null' : typeof obj)
     throw new Error(`Tag not resolved for ${name} value`)
   }
   return tagObj
diff --git a/src/stringify/stringifyString.ts b/src/stringify/stringifyString.ts
index b52ad9c..7f67fc9 100644
--- a/src/stringify/stringifyString.ts
+++ b/src/stringify/stringifyString.ts
@@ -294,13 +294,12 @@ function plainString(
     return quotedString(value, ctx)
   }
   if (
-    !value ||
     /^[\n\t ,[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$/.test(
       value
     )
   ) {
     // not allowed:
-    // - empty string, '-' or '?'
+    // - '-' or '?'
     // - start with an indicator character (except [?:-]) or /[?-] /
     // - '\n ', ': ' or ' \n' anywhere
     // - '#' not preceded by a non-space char


Test patch:
diff --git a/tests/cli.ts b/tests/cli.ts
index 7a0fbda..27c2eca 100644
--- a/tests/cli.ts
+++ b/tests/cli.ts
@@ -1,4 +1,4 @@
-import { Readable } from 'node:stream'
+import { Readable } from 'stream'
 import { cli } from 'yaml/cli'
 
 const [major] = process.versions.node.split('.')
@@ -208,6 +208,32 @@ const skip = Number(major) < 20
         ]
       )
     })
+    describe('--merge', () => {
+      ok(
+        'can be set',
+        'hello:\n  world: 2\nfoo:\n  world: 2',
+        ['--merge', '--json'],
+        ['[{"hello":{"world":2},"foo":{"world":2}}]']
+      )
+      ok(
+        'basic',
+        'hello: &a\n  world: 2\nfoo:\n  <<: *a',
+        ['--merge', '--json'],
+        ['[{"hello":{"world":2},"foo":{"world":2}}]']
+      )
+      ok(
+        'also enabled with --yaml=1.1',
+        'hello: &a\n  world: 2\nfoo:\n  <<: *a',
+        ['--yaml=1.1', '--json'],
+        ['[{"hello":{"world":2},"foo":{"world":2}}]']
+      )
+      ok(
+        'not enabled by default',
+        'hello: &a\n  world: 2\nfoo:\n  <<: *a',
+        ['--json'],
+        ['[{"hello":{"world":2},"foo":{"<<":{"world":2}}}]']
+      )
+    })
     describe('--doc', () => {
       ok('basic', 'hello: world', ['--doc'], [{ contents: { items: [{}] } }])
       ok(
diff --git a/tests/cst.ts b/tests/cst.ts
index b7b5f26..f2ccced 100644
--- a/tests/cst.ts
+++ b/tests/cst.ts
@@ -196,7 +196,7 @@ describe('CST.visit', () => {
   })
 })
 
-test('Line comment before unindented block-seq in block-map (eemeli/yaml#525)', () => {
+test('Line comment before unindented block-seq in block-map (#525)', () => {
   const src = 'a:\n#\n- b'
   const [doc] = Array.from(new Parser().parse(src))
   expect(CST.stringify(doc)).toBe(src)
diff --git a/tests/doc/anchors.ts b/tests/doc/anchors.ts
index 3946083..9e285ec 100644
--- a/tests/doc/anchors.ts
+++ b/tests/doc/anchors.ts
@@ -239,7 +239,12 @@ describe('merge <<', () => {
     for (let i = 5; i < res.length - 1; ++i) {
       expect(res[i]).toHaveProperty('<<')
     }
-    expect(res.at(-1)).toMatchObject({ x: 1, y: 2, r: 10, label: 'center/big' })
+    expect(res[res.length - 1]).toMatchObject({
+      x: 1,
+      y: 2,
+      r: 10,
+      label: 'center/big'
+    })
   })
 
   test('YAML.parseDocument', () => {
diff --git a/tests/doc/comments.ts b/tests/doc/comments.ts
index 22bfdad..630afbd 100644
--- a/tests/doc/comments.ts
+++ b/tests/doc/comments.ts
@@ -597,7 +597,7 @@ describe('stringify comments', () => {
       `)
     })
 
-    test('line comment after flow collection (eemeli/yaml#443)', () => {
+    test('line comment after flow collection (#443)', () => {
       const doc = YAML.parseDocument(source`
         [ value1, value2 ] # comment
       `)
@@ -847,7 +847,7 @@ describe('blank lines', () => {
     })
   })
 
-  test('blank line after less-indented comment (eemeli/yaml#91)', () => {
+  test('blank line after less-indented comment (#91)', () => {
     const src = `
 map:
   foo0:
@@ -1088,7 +1088,7 @@ map:
     })
   })
 
-  test('eemeli/yaml#277', () => {
+  test('trailing comment with trailing empty line (#277)', () => {
     const src = source`
       environment:
         ### SESSION START ###
@@ -1107,7 +1107,7 @@ map:
   })
 })
 
-describe('eemeli/yaml#18', () => {
+describe('comment after mapping key (#18)', () => {
   test('reported', () => {
     const src = `test1:
   foo:
@@ -1124,7 +1124,7 @@ describe('eemeli/yaml#18', () => {
   })
 })
 
-describe('eemeli/yaml#28', () => {
+describe('trailing comments on collection (#28)', () => {
   test('reported', () => {
     const src = `# This comment is ok
 entryA:
diff --git a/tests/doc/errors.ts b/tests/doc/errors.ts
index e18c640..fc85f76 100644
--- a/tests/doc/errors.ts
+++ b/tests/doc/errors.ts
@@ -19,13 +19,13 @@ describe('tabs as indentation', () => {
   })
 })
 
-test('eemeli/yaml#6', () => {
+test('end with missing block map : indicator (#6)', () => {
   const src = 'abc: 123\ndef'
   const doc = YAML.parseDocument(src)
   expect(doc.errors).toMatchObject([{ pos: [9, 12] }])
 })
 
-describe('eemeli/yaml#7', () => {
+describe('multiple trailing commas (#7)', () => {
   test('map', () => {
     const src = '{ , }\n---\n{ 123,,, }\n'
     const docs = YAML.parseAllDocuments(src)
@@ -89,17 +89,15 @@ describe('block collections', () => {
   test('seq item in mapping', () => {
     const src = 'foo: "1"\n- bar\n'
     const doc = YAML.parseDocument(src)
-    expect(doc.errors).toMatchObject(
-      [
-        'A block sequence may not be used as an implicit map key',
-        'Implicit keys need to be on a single line',
-        'Implicit map keys need to be followed by map values'
-      ].map(msg => ({ message: expect.stringContaining(msg) }))
-    )
+    expect(doc.errors).toMatchObject([
+      { code: 'MULTILINE_IMPLICIT_KEY' },
+      { code: 'UNEXPECTED_TOKEN' },
+      { code: 'MISSING_CHAR' }
+    ])
     expect(doc.contents).toMatchObject({
       items: [
         { key: { value: 'foo' }, value: { value: '1' } },
-        { key: { items: [{ value: 'bar' }] }, value: null }
+        { key: { value: null }, value: null }
       ]
     })
   })
@@ -132,7 +130,7 @@ describe('block collections', () => {
     ])
   })
 
-  test('key after long comment on empty value (eemeli/yaml#413)', () => {
+  test('key after long comment on empty value (#413)', () => {
     const doc = YAML.parseDocument(source`
       one:
         # large block of text, large block of text, large block of text, large block of text, large block of text,
@@ -150,7 +148,7 @@ describe('block collections', () => {
     expect(doc.errors).toMatchObject([])
   })
 
-  test('key after long comment on block map (eemeli/yaml#413)', () => {
+  test('key after long comment on block map (#413)', () => {
     const doc = YAML.parseDocument(source`
       one:
         sub: a
@@ -169,7 +167,7 @@ describe('block collections', () => {
     expect(doc.errors).toMatchObject([])
   })
 
-  test('key after long comment on block seq (eemeli/yaml#413)', () => {
+  test('key after long comment on block seq (#413)', () => {
     const doc = YAML.parseDocument(source`
       one:
         - a
@@ -187,15 +185,20 @@ describe('block collections', () => {
     `)
     expect(doc.errors).toMatchObject([])
   })
+
+  test('sequence with compact mapping value on same line with mapping key (#603)', () => {
+    const doc = YAML.parseDocument('abc: - foo: bar')
+    expect(doc.errors).toMatchObject([{ code: 'UNEXPECTED_TOKEN' }])
+  })
 })
 
 describe('flow collections', () => {
-  test('start only of flow map (eemeli/yaml#8)', () => {
+  test('start only of flow map (#8)', () => {
     const doc = YAML.parseDocument('{')
     expect(doc.errors).toMatchObject([{ code: 'MISSING_CHAR', pos: [1, 2] }])
   })
 
-  test('start only of flow sequence (eemeli/yaml#8)', () => {
+  test('start only of flow sequence (#8)', () => {
     const doc = YAML.parseDocument('[')
     expect(doc.errors).toMatchObject([{ code: 'MISSING_CHAR', pos: [1, 2] }])
   })
@@ -270,7 +273,7 @@ describe('comments', () => {
 })
 
 describe('pretty errors', () => {
-  test('eemeli/yaml#6', () => {
+  test('end with missing block map : indicator (#6)', () => {
     const src = 'abc: 123\ndef'
     const doc = YAML.parseDocument(src, { prettyErrors: true })
     expect(doc.errors).toMatchObject([
@@ -292,7 +295,7 @@ describe('pretty errors', () => {
     expect(doc.errors[0]).not.toHaveProperty('source')
   })
 
-  test('eemeli/yaml#7 maps', () => {
+  test('multiple trailing commas in mappings (#7)', () => {
     const src = '{ , }\n---\n{ 123,,, }\n'
     const docs = YAML.parseAllDocuments(src, { prettyErrors: true })
     expect(docs[0].errors).toMatchObject([
diff --git a/tests/doc/foldFlowLines.ts b/tests/doc/foldFlowLines.ts
index 3068b7c..a75aad5 100644
--- a/tests/doc/foldFlowLines.ts
+++ b/tests/doc/foldFlowLines.ts
@@ -145,7 +145,7 @@ describe('double-quoted', () => {
   })
 
   describe('Folding double-quoted strings', () => {
-    describe('eemeli/yaml#48: Split \\" escape in double-quoted string', () => {
+    describe('Split \\" escape in double-quoted string (#48)', () => {
       test('minimal', () => {
         const src2 = '"01234567\\""'
         expect(fold(src2, '', FOLD_QUOTED, options)).toBe('"01234567\\\n\\""')
@@ -163,27 +163,25 @@ describe('double-quoted', () => {
       })
     })
 
-    describe('eemeli/yaml#57', () => {
-      test('minimal', () => {
+    describe('Fold on multiple escaped characters', () => {
+      test('minimal (#57)', () => {
         const str = `"0123\\"\\\\ '"`
         expect(fold(str, '', FOLD_QUOTED, options)).toBe(`"0123\\"\\\\\n'"`)
       })
 
-      test('reported', () => {
+      test('reported (#57)', () => {
         const key2 = `!""""""""""""""""""""""""""""""""""#"\\ '`
         const str = YAML.stringify([{ key2 }])
         const res = YAML.parse(str)
         expect(res[0].key2).toBe(key2)
       })
-    })
 
-    describe('eemeli/yaml#59', () => {
-      test('minimal', () => {
+      test('minimal (#59)', () => {
         const str = `"######\\\\P#"`
         expect(fold(str, '', FOLD_QUOTED, options)).toBe(`"######\\\\\\\nP#"`)
       })
 
-      test('reported', () => {
+      test('reported (#59)', () => {
         const value =
           '>####################################"##########################\'####\\P#'
         const str = YAML.stringify({ key: [[value]] })
@@ -193,7 +191,7 @@ describe('double-quoted', () => {
       })
     })
 
-    describe('awslabs/cdk8s#494', () => {
+    describe('Fold on escaped character (awslabs/cdk8s#494)', () => {
       test('slash', () => {
         const str = `"1234567\\\\ab"`
         expect(fold(str, '', FOLD_QUOTED, options)).toBe(`"1234567\\\n\\\\ab"`)
@@ -211,7 +209,7 @@ describe('double-quoted', () => {
     })
   })
 
-  describe('Folding input with excessive start indentation - eemeli/yaml#196', () => {
+  describe('Folding input with excessive start indentation (#196)', () => {
     test('quoted', () => {
       options.indentAtStart = 6
       options.minContentWidth = 6
@@ -251,7 +249,7 @@ describe('double-quoted', () => {
 })
 
 describe('block scalar', () => {
-  test('eemeli/yaml#422', () => {
+  test('Indented first line (#422)', () => {
     const obj = {
       'nginx.ingress.kubernetes.io/configuration-snippet': source`
           location ~* ^/sites/aaaaaaa.aa/files/(.+) {
@@ -319,7 +317,7 @@ describe('end-to-end', () => {
     expect(doc.toString(foldOptions)).toBe(src)
   })
 
-  test('eemeli/yaml#55', () => {
+  test('More-indented first line (#55)', () => {
     const str = ' first more-indented line\nnext line\n'
     const ys = YAML.stringify(str, foldOptions)
     expect(ys).toBe('>1\n first more-indented line\nnext line\n')
diff --git a/tests/doc/parse.ts b/tests/doc/parse.ts
index 24655ef..c8aa057 100644
--- a/tests/doc/parse.ts
+++ b/tests/doc/parse.ts
@@ -15,15 +15,7 @@ describe('scalars', () => {
   })
 })
 
-test('eemeli/yaml#3', () => {
-  const src = '{ ? : 123 }'
-  const doc = YAML.parseDocument<any>(src)
-  expect(doc.errors).toHaveLength(0)
-  expect(doc.contents.items[0].key.value).toBeNull()
-  expect(doc.contents.items[0].value.value).toBe(123)
-})
-
-describe('eemeli/yaml#10', () => {
+describe('indented block sequence (#10)', () => {
   test('reported', () => {
     const src = `
 aliases:
@@ -154,7 +146,7 @@ aliases:
   })
 })
 
-test('buffer as source (eemeli/yaml#459)', () => {
+test('buffer as source (#459)', () => {
   const buffer = readFileSync(
     resolve(__dirname, '../artifacts/prettier-circleci-config.yml')
   )
@@ -163,39 +155,8 @@ test('buffer as source (eemeli/yaml#459)', () => {
   )
 })
 
-describe('eemeli/yaml#19', () => {
-  test('map', () => {
-    const src = 'a:\n  # 123'
-    const doc = YAML.parseDocument(src)
-    expect(String(doc)).toBe('a: # 123\n')
-  })
-
-  test('seq', () => {
-    const src = '- a: # 123'
-    const doc = YAML.parseDocument(src)
-    expect(String(doc)).toBe('- a: # 123\n')
-  })
-})
-
-test('eemeli/yaml#32', () => {
-  expect(YAML.parse('[ ? ]')).toEqual([{ '': null }])
-  expect(YAML.parse('[? 123]')).toEqual([{ 123: null }])
-  expect(YAML.parse('[ 123, ? ]')).toEqual([123, { '': null }])
-  expect(YAML.parse('[ 123, ? 456 ]')).toEqual([123, { 456: null }])
-})
-
-describe('block scalars', () => {
-  test('eemeli/yaml#34', () => {
-    expect(YAML.parse('|')).toEqual('')
-  })
-
-  test('eemeli/yaml#313', () => {
-    expect(YAML.parse('|+\n   \n\n     \n')).toEqual('\n\n\n')
-  })
-})
-
-test('eemeli/yaml#36', () => {
-  expect(() => YAML.parse(`{ x: ${'x'.repeat(1024)} }`)).not.toThrowError()
+test('long scalar value in flow map (#36)', () => {
+  expect(() => YAML.parse(`{ x: ${'x'.repeat(1024)} }`)).not.toThrow()
 })
 
 describe('flow collection keys', () => {
@@ -275,7 +236,7 @@ describe('flow collection keys', () => {
   })
 })
 
-test('eemeli/yaml#38', () => {
+test('indented block sequnce with inner block sequence (#38)', () => {
   const src = `
   content:
     arrayOfArray:
@@ -305,7 +266,7 @@ test('eemeli/yaml#38', () => {
   })
 })
 
-test('eemeli/yaml#120', () => {
+test('stream end after : indicator (#120)', () => {
   const src = `test:
     - test1: test1
       test2:`
@@ -314,7 +275,7 @@ test('eemeli/yaml#120', () => {
   })
 })
 
-test('comment between key & : in flow collection (eemeli/yaml#149)', () => {
+test('comment between key & : in flow collection (#149)', () => {
   const src1 = '{"a"\n#c\n:1}'
   expect(YAML.parse(src1)).toEqual({ a: 1 })
 
@@ -323,7 +284,7 @@ test('comment between key & : in flow collection (eemeli/yaml#149)', () => {
   expect(doc.errors).toMatchObject([{ code: 'MISSING_CHAR' }])
 })
 
-describe('indented key with anchor (eemeli/yaml#378)', () => {
+describe('indented key with anchor (#378)', () => {
   test('followed by value', () => {
     const src1 = '&a foo: 1\n&b bar: 2'
     expect(YAML.parse(src1)).toEqual({ foo: 1, bar: 2 })
@@ -344,6 +305,45 @@ describe('indented key with anchor (eemeli/yaml#378)', () => {
 })
 
 describe('empty(ish) nodes', () => {
+  test('empty explicit key (#3)', () => {
+    const src = '{ ? : 123 }'
+    const doc = YAML.parseDocument<any>(src)
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.contents.items[0].key.value).toBeNull()
+    expect(doc.contents.items[0].value.value).toBe(123)
+  })
+
+  describe('comment on empty pair value (#19)', () => {
+    test('map', () => {
+      const src = 'a:\n  # 123'
+      const doc = YAML.parseDocument(src)
+      expect(String(doc)).toBe('a: # 123\n')
+    })
+
+    test('seq', () => {
+      const src = '- a: # 123'
+      const doc = YAML.parseDocument(src)
+      expect(String(doc)).toBe('- a: # 123\n')
+    })
+  })
+
+  test('explicit key with empty value (#32)', () => {
+    expect(YAML.parse('[ ? ]')).toEqual([{ '': null }])
+    expect(YAML.parse('[? 123]')).toEqual([{ 123: null }])
+    expect(YAML.parse('[ 123, ? ]')).toEqual([123, { '': null }])
+    expect(YAML.parse('[ 123, ? 456 ]')).toEqual([123, { 456: null }])
+  })
+
+  describe('empty block scalars', () => {
+    test('no body (#34)', () => {
+      expect(YAML.parse('|')).toEqual('')
+    })
+
+    test('whitespace with indents (#313)', () => {
+      expect(YAML.parse('|+\n   \n\n     \n')).toEqual('\n\n\n')
+    })
+  })
+
   test('empty node position', () => {
     const doc = YAML.parseDocument<any>('\r\na: # 123\r\n')
     const empty = doc.contents.items[0].value
@@ -445,6 +445,12 @@ describe('odd indentations', () => {
     expect(doc.errors).toHaveLength(0)
     expect(doc.contents).toMatchObject({ value: 'x\n' })
   })
+
+  test('tab after indent spaces for flow-in-block (#604)', () => {
+    const doc = YAML.parseDocument<YAML.YAMLMap, false>('foo:\n \tbar')
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.toJS()).toMatchObject({ foo: 'bar' })
+  })
 })
 
 describe('Excessive entity expansion attacks', () => {
@@ -624,6 +630,24 @@ describe('handling complex keys', () => {
     expect(obj).toMatchObject([{ '[ foo ]': 'bar' }])
     expect(process.emitWarning).toHaveBeenCalled()
   })
+
+  test('Error on unresolved !!binary node with mapAsMap: false (#610)', () => {
+    process.emitWarning = jest.fn()
+    const doc = YAML.parseDocument('? ? !!binary ? !!binary')
+    expect(doc.warnings).toMatchObject([{ code: 'BAD_COLLECTION_TYPE' }])
+    doc.toJS()
+    expect(process.emitWarning).toHaveBeenCalled()
+  })
+
+  test('Error on unresolved !!timestamp node with mapAsMap: false (#610)', () => {
+    process.emitWarning = jest.fn()
+    const doc = YAML.parseDocument(
+      '? ? !!timestamp ? !!timestamp 2025-03-15T15:35:58.586Z'
+    )
+    expect(doc.warnings).toMatchObject([{ code: 'BAD_COLLECTION_TYPE' }])
+    doc.toJS()
+    expect(process.emitWarning).toHaveBeenCalled()
+  })
 })
 
 test('Document.toJS({ onAnchor })', () => {
@@ -681,7 +705,7 @@ describe('keepSourceTokens', () => {
     })
   }
 
-  test('allow for CST modifications (eemeli/yaml#903)', () => {
+  test('allow for CST modifications (#903)', () => {
     const src = 'foo:\n  [ 42 ]'
     const tokens = Array.from(new YAML.Parser().parse(src))
     const docs = new YAML.Composer<YAML.ParsedNode, false>({
diff --git a/tests/doc/stringify.ts b/tests/doc/stringify.ts
index 82e18b8..f263f28 100644
--- a/tests/doc/stringify.ts
+++ b/tests/doc/stringify.ts
@@ -519,14 +519,14 @@ z:
   })
 })
 
-test('eemeli/yaml#43: Quoting colons', () => {
+test('Quoting colons (#43)', () => {
   const doc = new YAML.Document({ key: ':' })
   const str = String(doc)
   expect(() => YAML.parse(str)).not.toThrow()
   expect(str).toBe('key: ":"\n')
 })
 
-test('eemeli/yaml#52: Quoting item markers', () => {
+test('Quoting item markers (#52)', () => {
   const doc = new YAML.Document({ key: '-' })
   const str = String(doc)
   expect(() => YAML.parse(str)).not.toThrow()
@@ -542,7 +542,7 @@ test('reserved names', () => {
   expect(str).toBe('comment: foo\n')
 })
 
-describe('eemeli/yaml#85', () => {
+describe('Collection pairs with single-line flow collections (#85)', () => {
   test('reported', () => {
     const str = `testArray: []\ntestObject: {}\ntestArray2: [ "hello" ]\n`
     const doc = YAML.parseDocument(str)
@@ -558,7 +558,7 @@ describe('eemeli/yaml#85', () => {
   })
 })
 
-test('eemeli/yaml#87', () => {
+test('set(object) in collection (#87)', () => {
   const doc = YAML.parseDocument('test: x')
   doc.set('test', { a: 'test' })
   expect(String(doc)).toBe('test:\n  a: test\n')
@@ -753,7 +753,7 @@ describe('simple keys', () => {
   })
 })
 
-test('eemeli/yaml#128: YAML node inside object', () => {
+test('YAML node inside object (#128)', () => {
   const doc = new YAML.Document()
   const seq = doc.createNode(['a'])
   seq.commentBefore = 'sc'
@@ -921,7 +921,7 @@ describe('lineWidth', () => {
     )
   })
 
-  test('less than minContentWidth (eemeli/yaml#562)', () => {
+  test('less than minContentWidth (#562)', () => {
     const doc = YAML.parseDocument('>\ntest test test test test')
     expect(doc.toString({ lineWidth: 18, minContentWidth: 20 })).toBe(
       '>\ntest test test\ntest test\n'
@@ -1197,7 +1197,7 @@ describe('Document markers in top-level scalars', () => {
   })
 })
 
-describe('Document markers in top-level map keys (eemeli/yaml#431)', () => {
+describe('Document markers in top-level map keys (#431)', () => {
   test('---', () => {
     const str = YAML.stringify({ '--- x': 42 })
     expect(str).toBe('"--- x": 42\n')
diff --git a/tests/doc/types.ts b/tests/doc/types.ts
index 8c982e8..4f91a23 100644
--- a/tests/doc/types.ts
+++ b/tests/doc/types.ts
@@ -107,20 +107,20 @@ describe('tags', () => {
       })
     }
 
-    test('malformed URI (eemeli/yaml#498)', () => {
+    test('malformed URI (#498)', () => {
       const doc = parseDocument('!!%ee 0')
       expect(doc.errors).toHaveLength(1)
       expect(doc.errors[0].message).toMatch('URIError')
     })
 
-    test('CR in tag shorthand (eemeli/yaml#501', () => {
+    test('CR in tag shorthand (#501)', () => {
       const doc = parseDocument(': | !\r!')
       const err = doc.errors.find(err => err.code === 'TAG_RESOLVE_FAILED')
       expect(err).not.toBeFalsy()
     })
   })
 
-  test('eemeli/yaml#97', () => {
+  test('float with fraction digits (#97)', () => {
     const doc = parseDocument('foo: !!float 3.0')
     expect(String(doc)).toBe('foo: !!float 3.0\n')
   })
@@ -250,7 +250,7 @@ describe('number types', () => {
   })
 })
 
-test('eemeli/yaml#2', () => {
+test('Indented sequence with sequence values (#2)', () => {
   const src = `
 aliases:
   - docker:
@@ -264,6 +264,16 @@ aliases:
   })
 })
 
+describe('failsafe schema', () => {
+  test('empty scalars (#616)', () => {
+    const doc = parseDocument('empty:\n', { schema: 'failsafe' })
+    expect(doc.errors).toMatchObject([])
+    expect(doc.get('empty', true)).toMatchObject({ value: '' })
+    expect(doc.toJS()).toMatchObject({ empty: '' })
+    expect(doc.toString()).toEqual('empty:\n')
+  })
+})
+
 describe('json schema', () => {
   test('!!bool', () => {
     const src = `"canonical": true
@@ -844,7 +854,7 @@ date (00:00:00Z): 2002-12-14\n`)
       expect(str2).toBe(`- a\n- b\n- c\n`)
     })
 
-    test('eemeli/yaml#78', () => {
+    test('stringify in collection (#78)', () => {
       const set = new Set(['a', 'b', 'c'])
       const str = stringify({ set }, { version: '1.1' })
       expect(str).toBe(source`
@@ -1215,6 +1225,12 @@ describe('schema changes', () => {
     expect(() => String(doc)).toThrow(/Tag not resolved for Date value/)
   })
 
+  test('fail for missing null type (#616)', () => {
+    const doc = parseDocument('foo:')
+    doc.setSchema('1.2', { schema: 'failsafe' })
+    expect(() => String(doc)).toThrow(/Tag not resolved for null value/)
+  })
+
   test('set schema + custom tags', () => {
     const doc = parseDocument('foo: 1971-02-03T12:13:14', {
       version: '1.1'
diff --git a/tests/line-counter.ts b/tests/line-counter.ts
index 3ae7256..22a7cf1 100644
--- a/tests/line-counter.ts
+++ b/tests/line-counter.ts
@@ -11,7 +11,7 @@ test('Parse error, no newlines', () => {
 test('Parse error with newlines', () => {
   const lineCounter = new LineCounter()
   const doc = parseDocument('foo:\n  bar: - baz\n', { lineCounter })
-  expect(doc.errors).toMatchObject([{ pos: [14, 17] }])
+  expect(doc.errors).toMatchObject([{ pos: [12, 13] }, { pos: [14, 17] }])
   expect(lineCounter.lineStarts).toMatchObject([0, 5, 18])
   expect(lineCounter.linePos(14)).toMatchObject({ line: 2, col: 10 })
   expect(lineCounter.linePos(17)).toMatchObject({ line: 2, col: 13 })