Repository: eemeli/yaml
Pull request #612 (2025-03-28 03:10:25)

feat: Build cached anchor/alias node list for faster alias resolving
Currently, to resolve an alias, we have to visit the whole document tree until the alias node is found. The lower in the file we put an alias, the slower the resolution is.

This is an attempt to speed this process significantly by:
1. Putting all nodes in a list and iterating over it, this is significantly faster than descending the tree via visitor every time
2. If we are putting nodes into a list we might as well filter them to only the ones that are needed for alias resolution, so only aliases and anchors are cached

Not sure if this is the right approach, but it helped me reduce the load time of my dataset, which is not very alias-heavy, from 10s to 4s.


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/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/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)


Test patch:
diff --git a/tests/cli.ts b/tests/cli.ts
index 7a0fbda..14ed144 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('.')
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 71a5d6e..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', () => {
@@ -699,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..9aedb0f 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:
@@ -844,7 +844,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`
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 })