Files
pnpm/pnpm11/yaml/document-sync/test/patchDocument.test.ts
Zoltan KochanandClaude Opus 5.5 6f95957063 docs: lint prose with Vale (#17238)
* docs: lint prose with Vale

Add Vale with an in-repo style, .vale/styles/pnpm, that turns the
writing rules in AGENTS.md and the Google developer documentation style
guide into lint rules. The stock Vale packages (Google, Microsoft,
RedHat, write-good) were too noisy to block on, for example 1,300
passive-voice hits in the v12 docs alone, so the rules are written for
this repo, following the GitLab model. Each one links the guideline it
enforces.

Levels:
- error, fails CI: dashes, plain-word substitutions, condescending
  words, "please", "note that", hedges, puffery, optional plurals,
  exclamation marks, ellipses, curly quotes, generic link text, product
  spellings, sentence-case headings, repeated words, double spaces,
  implementation history in comments; in changesets, bold or italic
  emphasis, internal names and release-line names.
- warning: future tense, sentences over 30 words, "instead of" and
  "so that" tails in changesets.
- suggestion: passive voice.

.vale.ini scopes the rules. Docs get every rule. Contributor Markdown
and code comments keep dashes and headings as warnings until their
roughly 8,100 em dashes are rewritten. Released changesets stay in
.changeset/ and are not rewritten, so lint-prose.mjs lints only the
changesets added since PROSE_BASE (the PR base in CI).

The prose workflow installs a pinned, checksum-verified Vale release and
runs `pnpm lint:prose`. The rest of this change rewrites the existing
docs, READMEs, contributor Markdown, and code comments to pass the
error-level rules. Only prose and comment text changed. Renamed
headings keep their anchors.

Spelling stays with cspell, which checks identifiers that Vale does not
see.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: keep prose lint from flattening meaning

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:48:09 +02:00

870 lines
19 KiB
TypeScript

import { describe, expect, it } from '@jest/globals'
import { patchDocument } from '@pnpm/yaml.document-sync'
import yaml from 'yaml'
describe('patchNode', () => {
it('throws error when document has errors', () => {
const raw = `\
foo:
bar: 1
- 2
`
const document = yaml.parseDocument(raw)
expect(() => {
patchDocument(document, {})
}).toThrow('Document with errors cannot be patched')
})
it('throws error when encountering unknown node at top-level', () => {
const raw = `\
- 1
- 2
`
const document = yaml.parseDocument(raw);
// Inserting a raw value that should definitely be wrong.
(document.contents as unknown as number) = 3
expect(() => {
patchDocument(document, [1, 2, 3])
}).toThrow('Unrecognized yaml node')
})
it('empties document when target is null', () => {
const raw = `\
- 1
- 2
- 3
`
const document = yaml.parseDocument(raw)
patchDocument(document, null)
expect(document.contents).toBeNull()
})
})
describe('scalar', () => {
it('updates nested scalar', () => {
const raw = `\
foo:
bar:
# This comment on baz should be preserved when changing it.
baz: 1
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
json.foo.bar.baz = 2
patchDocument(document, json)
expect(document.toString()).toBe(`\
foo:
bar:
# This comment on baz should be preserved when changing it.
baz: 2
`)
})
it('changes from a scalar to a different type', () => {
const raw = `\
foo:
bar:
# This comment on baz should be preserved when changing it.
baz: 1
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
json.foo.bar.baz = [1, 2]
patchDocument(document, json)
expect(document.toString()).toBe(`\
foo:
bar:
# This comment on baz should be preserved when changing it.
baz:
- 1
- 2
`)
})
})
it('does not reformat string quotes', () => {
const raw = `\
foo:
bar:
baz: [ '1', "2" ]
qux:
- "1"
- '2'
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
json.foo.quux = 3
patchDocument(document, json)
expect(document.toString()).toBe(`\
foo:
bar:
baz: [ '1', "2" ]
qux:
- "1"
- '2'
quux: 3
`)
})
describe('map', () => {
it('adds new items to a map and preserves comment', () => {
const raw = `\
foo:
bar:
# Comment 1
baz: 1
# Comment 2
qux: 2
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
json.foo.quux = 3
patchDocument(document, json)
expect(document.toString()).toBe(`\
foo:
bar:
# Comment 1
baz: 1
# Comment 2
qux: 2
quux: 3
`)
})
it('adds new items to a map and handles comment immediately after map definition', () => {
const raw = `\
items:
# Comment on items in map
b: 2
# Comment on d
d: 4
`
const document = yaml.parseDocument(raw)
patchDocument(document, { items: { a: 1, b: 2, c: 3, d: 4 } })
// The yaml library unfortunately parses the first comment as a property on
// the "items" map rather than a property on the "b" field. So the newly
// added "a" field is added below the comment.
//
// This isn't incorrect, but most of the time users likely associate the
// comment as a part of the immediately succeeding field. Let's encode the
// behavior as a test for now. The behavior may change in a future version
// of the yaml library.
expect(document.toString()).toBe(`\
items:
# Comment on items in map
a: 1
b: 2
c: 3
# Comment on d
d: 4
`)
})
it('removes item from map and preserves comment', () => {
const raw = `\
foo:
bar:
# Comment 1
baz: 1
# Comment 2
qux: 2
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
delete json.foo.bar.baz
patchDocument(document, json)
expect(document.toString()).toBe(`\
foo:
# Comment 2
qux: 2
`)
})
it('changes from a map to a different type', () => {
const raw = `\
foo:
bar:
baz: 1
qux: 2
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
// Change foo.bar to be a list instead.
json.foo.bar = [1, 2, 3]
patchDocument(document, json)
expect(document.toString()).toBe(`\
foo:
bar:
- 1
- 2
- 3
qux: 2
`)
})
it('uses key order from target map', () => {
const raw = `\
# a
a: 1
# b
b: 2
# c
c: 3
# d
d: 4
# e
e: 5
`
const document = yaml.parseDocument(raw)
const target = {
d: 4,
c: 3,
a: 1,
e: 5,
b: 2,
}
patchDocument(document, target)
expect(document.toString()).toBe(`\
# d
d: 4
# c
c: 3
# a
a: 1
# e
e: 5
# b
b: 2
`)
})
it('throws error when encountering unknown key node in map', () => {
const raw = `\
foo: 1
`
const document = yaml.parseDocument(raw)
const contents = document.contents as yaml.YAMLMap
// The key here should also be wrapped around a scalar constructor.
contents.items.push(new yaml.Pair('bar', new yaml.Scalar('2')))
expect(() => {
patchDocument(document, { foo: 1, bar: 2 })
}).toThrow('Encountered unexpected non-node value: bar')
})
it('throws error when encountering unknown value node in map', () => {
const raw = `\
foo: 1
`
const document = yaml.parseDocument(raw)
const contents = document.contents as yaml.YAMLMap
// The value here should also be wrapped around a scalar constructor.
contents.items.push(new yaml.Pair(new yaml.Scalar('bar'), 2))
expect(() => {
patchDocument(document, { foo: 1, bar: 2 })
}).toThrow('Encountered unexpected non-node value: 2')
})
})
describe('list', () => {
it('adds new items to a list and preserves comment', () => {
const raw = `\
foo:
bar:
baz:
- 1
# Comment
- 2
- 3
qux: 2
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
json.foo.bar.baz.push(4)
patchDocument(document, json)
expect(document.toString()).toBe(`\
foo:
bar:
baz:
- 1
# Comment
- 2
- 3
- 4
qux: 2
`)
})
it('removes items from a list along with its comment', () => {
const raw = `\
list:
- 1
# Comment
- 2
- 3
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
delete json.list[1]
patchDocument(document, json)
expect(document.toString()).toBe(`\
list:
- 1
- 3
`)
})
it('removes items from a list but preserves comments below', () => {
const raw = `\
- 1
- 2
# Comment on 3
- 3
# Comment on 4
- 4
`
const document = yaml.parseDocument(raw)
patchDocument(document, [1, 3, 4])
expect(document.toString()).toBe(`\
- 1
# Comment on 3
- 3
# Comment on 4
- 4
`)
})
it('updates items in a list that contain duplicates', () => {
const raw = `\
# Comment on first instance of 1
- 1
- 2
# Comment on second instance of 1
- 1
# Comment on 4
- 4
`
const document = yaml.parseDocument(raw)
patchDocument(document, [1, 3, 4, 1])
expect(document.toString()).toBe(`\
# Comment on first instance of 1
- 1
- 3
# Comment on 4
- 4
# Comment on second instance of 1
- 1
`)
})
// Similar to the test above, but make sure the presence of a complex object
// doesn't cause the list reconciler to fall back a different code path that
// won't handle primitives efficiently.
it('removes items from a list but preserves comments below when source list has complex object', () => {
const raw = `\
- 1
- {}
- 2
# Comment on 3
- 3
# Comment on 4
- 4
`
const document = yaml.parseDocument(raw)
patchDocument(document, [1, 3, 4])
expect(document.toString()).toBe(`\
- 1
# Comment on 3
- 3
# Comment on 4
- 4
`)
})
it('updates items in a complex list', () => {
const raw = `\
# Comment on foo
- foo: 1
# Comment on qux
- qux: 2
`
const document = yaml.parseDocument(raw)
patchDocument(document, [{ foo: 1 }, { bar: 2 }, { qux: 3 }])
// It's unfortunately very difficult (and inherently ambiguous) to keep the
// comment on qux in the right place. This is because the complex list item
// reconciler is index based and doesn't know qux shifted down one element.
//
// It's especially difficult to tell where the comment on qux should be when
// its value changes too like in this example (qux: 2 -> 3).
expect(document.toString()).toBe(`\
# Comment on foo
- foo: 1
# Comment on qux
- bar: 2
- qux: 3
`)
})
it('updates items in primitive list with holes', () => {
const raw = `\
- 1
- 2
# Comment on 3
- 3
# Comment on 4
- 4
`
const document = yaml.parseDocument(raw)
patchDocument(document, [1, 3, null, undefined, 4])
expect(document.toString()).toBe(`\
- 1
# Comment on 3
- 3
# Comment on 4
- 4
`)
})
it('updates items in complex list with holes', () => {
const raw = `\
- foo: 1
- 2
- 3
- 4
`
const document = yaml.parseDocument(raw)
patchDocument(document, [{ foo: 1 }, 3, null, undefined, 4, 5])
expect(document.toString()).toBe(`\
- foo: 1
- 3
- 4
- 5
`)
})
// This may not be the desired behavior in every case. It's inherently
// ambiguous and depends on whether the comment written applies to the newly
// added item.
it('changes item in list and removes comment', () => {
const raw = `\
- 1
# Comment on 2
- 2
- 5
`
const document = yaml.parseDocument(raw)
patchDocument(document, [1, 3, 4, 5])
expect(document.toString()).toBe(`\
- 1
- 3
- 4
- 5
`)
})
it('changes from a list to a different type', () => {
const raw = `\
foo:
bar:
- 1
- 2
qux: 2
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
json.foo.bar = { baz: 1 }
patchDocument(document, json)
expect(document.toString()).toBe(`\
foo:
bar:
baz: 1
qux: 2
`)
})
it('throws error when encountering unknown node in primitive list', () => {
const raw = `\
- 1
- 2
`
const document = yaml.parseDocument(raw)
const contents = document.contents as yaml.YAMLSeq
// The correct way to modify the AST would be:
//
// content.items.push(new yaml.Scalar(3))
//
// Inserting the raw value should cause the patch function to throw.
contents.items.push(3)
expect(() => {
patchDocument(document, [1, 2, 3])
}).toThrow('Encountered unexpected non-node value: 3')
})
it('throws error when encountering unknown node in complex list', () => {
const raw = `\
- foo: 1
- bar: 2
`
const document = yaml.parseDocument(raw)
const contents = document.contents as yaml.YAMLSeq
// The correct way to modify the AST would be:
//
// content.items.push(new yaml.Scalar(3))
//
// Inserting the raw value should cause the patch function to throw.
contents.items.push({ qux: 3 })
expect(() => {
patchDocument(document, [{ foo: 1 }, { bar: 2 }, { qux: 3 }])
}).toThrow('Encountered unexpected non-node value: [object Object]')
})
})
describe('alias', () => {
it.each([
[{ react: '^2.0.0', 'react-dom': '^2.0.0' }, 'react: &react ^2.0.0\nreact-dom: *react\n'],
[{ react: '^2.0.0', 'react-dom': '^1.0.0' }, 'react: &react ^2.0.0\nreact-dom: ^1.0.0\n'],
[{ 'react-dom': '^1.0.0' }, 'react-dom: &react ^1.0.0\n'],
[{ 'react-dom': '^2.0.0', react: '^2.0.0' }, 'react-dom: &react ^2.0.0\nreact: *react\n'],
])('preserves scalar aliases when possible for %j', (target, expected) => {
const document = yaml.parseDocument('react: &react ^1.0.0\nreact-dom: *react\n')
patchDocument(document, target, { preserveScalarAliases: true })
expect(document.toString()).toBe(expected)
expect(yaml.parse(document.toString())).toEqual(target)
})
it('updates aliases in original location when alias=follow', () => {
const raw = `\
foo: &config
- 1
- 2
bar: *config
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
// When aliases are used, the toJSON function will reuse the same object. We
// have to create a new list to get a representative test.
json.bar = [...json.bar, 3]
patchDocument(document, json, { aliases: 'follow' })
expect(document.toString()).toBe(`\
foo: &config
- 1
- 2
- 3
bar: *config
`)
})
it('removes alias when alias=unwrap', () => {
const raw = `\
foo: &config
- 1
- 2
bar: *config
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
// When aliases are used, the toJSON function will reuse the same object. We
// have to create a new list to get a representative test.
json.bar = [...json.bar, 3]
patchDocument(document, json, { aliases: 'unwrap' })
expect(document.toString()).toBe(`\
foo: &config
- 1
- 2
bar:
- 1
- 2
- 3
`)
})
it('updates anchor nodes when alias=follow', () => {
const raw = `\
foo: &config
- 1
- 2
bar: *config
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
// When aliases are used, the toJSON function will reuse the same object. We
// have to create a new list to get a representative test.
json.bar = [...json.bar, 3]
patchDocument(document, json, { aliases: 'follow' })
expect(document.toString()).toBe(`\
foo: &config
- 1
- 2
- 3
bar: *config
`)
})
it('alias unwraps correctly when modifying anchor node', () => {
const raw = `\
foo: &config
- 1
- 2
bar: *config
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
// When aliases are used, the toJSON function will reuse the same object. We
// have to create a new list to get a representative test.
json.foo = [...json.foo, 3]
patchDocument(document, json, { aliases: 'unwrap' })
expect(document.toString()).toBe(`\
foo: &config
- 1
- 2
- 3
bar:
- 1
- 2
`)
})
// It's not completely clear what to do in this case. The library uses the value of the last encounter.
it('updates anchor and alias nodes with conflicting values when alias=follow', () => {
const raw = `\
foo: &config
- 1
- 2
bar: *config
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
// When aliases are used, the toJSON function will reuse the same object. We
// have to create a new list to get a representative test.
json.foo = [...json.foo, 3]
json.bar = [...json.bar, 4]
patchDocument(document, json, { aliases: 'follow' })
expect(document.toString()).toBe(`\
foo: &config
- 1
- 2
- 4
bar: *config
`)
})
it('throws explicit error when encountering unresolved alias', () => {
const raw = `\
foo: &config
- 1
- 2
bar: *config
`
const document = yaml.parseDocument(raw)
const json = document.toJSON()
const contents = document.contents as yaml.YAMLMap
const foo = contents.get('foo') as yaml.YAMLSeq
foo.anchor = undefined
// When aliases are used, the toJSON function will reuse the same object. We
// have to create a new list to get a representative test.
json.bar = [...json.bar, 3]
expect(() => {
patchDocument(document, json)
}).toThrow('Failed to resolve yaml alias: config')
})
})
it('keeps repeated scalar anchor names independent when entries are reordered', () => {
const document = yaml.parseDocument('a: &version ^1.0.0\nb: *version\nc: &version ^2.0.0\nd: *version\n')
const target = { a: '^3.0.0', c: '^2.0.0', b: '^3.0.0', d: '^2.0.0' }
patchDocument(document, target, { preserveScalarAliases: true })
expect(yaml.parse(document.toString())).toEqual(target)
expect(document.toString()).toContain('b: *version_1')
expect(document.toString()).toContain('d: *version_2')
})
it('preserves scalar alias comments and multiline values', () => {
const raw = 'description: &text |\n café\n second line\nextraEnv:\n DESCRIPTION: *text # consumer\n'
const document = yaml.parseDocument(raw)
const target = { ...document.toJSON(), catalog: { react: '^1.0.0' } }
patchDocument(document, target, { preserveScalarAliases: true })
expect(document.toString()).toBe(raw + 'catalog:\n react: ^1.0.0\n')
expect(yaml.parse(document.toString())).toEqual(target)
})
it('does not bind scalar aliases to anchors copied from an aliased collection', () => {
const document = yaml.parseDocument('defaults: &defaults { version: &version old }\ncopy: *defaults\ncatalog: { a: *version, b: *version }\n')
const target = { defaults: { version: 'old' }, copy: { version: 'new' }, catalog: { a: 'old', b: 'old' } }
patchDocument(document, target, { preserveScalarAliases: true })
expect(yaml.parse(document.toString())).toEqual(target)
})
it('clears the old anchor when a preceding alias is promoted', () => {
const document = yaml.parseDocument('a: &version old\nb: *version\nc: *version\n')
const target = { b: 'old', a: 'new', c: 'old' }
patchDocument(document, target, { preserveScalarAliases: true })
expect(yaml.parse(document.toString())).toEqual(target)
})
it('preserves aliases nested beneath an alias mapping key', () => {
const document = yaml.parseDocument('name: &key custom\ncatalog:\n react: &version ^1.0.0\n*key :\n version: *version\n')
const target = { ...document.toJSON(), saveExact: true }
patchDocument(document, target, { preserveScalarAliases: true })
expect(yaml.parse(document.toString())).toEqual(target)
expect(document.toString()).toContain('version: *version')
expect(document.toString()).toContain('? *key\n')
})
describe('manifest preservation options', () => {
it('preserves existing key order recursively and appends new keys', () => {
const document = yaml.parseDocument('z: { z: 1, a: 2 }\na: 3\nremoved: 4\n')
patchDocument(document, { a: 4, b: 5, z: { a: 3, b: 4, z: 2 } }, { preserveKeyOrder: true })
expect(document.toString()).toBe('z: { z: 2, a: 3, b: 4 }\na: 4\nb: 5\n')
})
it.each([
{ empty: {}, nil: null, values: [null, {}, [], 1] },
{},
null,
])('retains empty and null values when pruning is disabled: %j', (target) => {
const document = yaml.parseDocument('empty: { old: value }\nnil: value\nvalues: [1, 2, 3]\n')
patchDocument(document, target, { pruneEmptyValues: false })
expect(yaml.parse(document.toString())).toStrictEqual(target)
})
})
it('removes trailing sequence items without inserting null when pruning is disabled', () => {
const document = yaml.parseDocument('values: [{ old: 1 }, { old: 2 }]\n')
patchDocument(document, { values: [{}] }, { pruneEmptyValues: false })
expect(document.toJSON()).toStrictEqual({ values: [{}] })
})
it('replaces a collection alias when its value changes type', () => {
const document = yaml.parseDocument('metadata: &metadata { value: 1 }\ncopy: *metadata\n')
patchDocument(document, { metadata: { value: 1 }, copy: 'changed' }, { pruneEmptyValues: false })
expect(document.toJSON()).toStrictEqual({ metadata: { value: 1 }, copy: 'changed' })
})
it('matches scalar mapping keys to their JSON property names', () => {
const document = yaml.parseDocument('1: one # numeric\ntrue: enabled\nnull: empty\n')
patchDocument(document, { 1: 'first', true: 'yes', '': 'blank' }, { preserveKeyOrder: true })
expect(document.toJSON()).toStrictEqual({ 1: 'first', true: 'yes', '': 'blank' })
expect(document.toString()).toBe('1: first # numeric\ntrue: yes\nnull: blank\n')
})
it('supports consumers that stringify null mapping keys', () => {
const document = yaml.parseDocument('null: empty # null key\n')
patchDocument(document, { null: 'updated' }, { stringifyKey: String })
expect(document.toString()).toBe('null: updated # null key\n')
})