generated-signature 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +268 -2
- package/dist/eslint/plugin.d.ts +22 -0
- package/dist/eslint/plugin.js +24 -0
- package/dist/eslint/rules/valid.d.ts +6 -0
- package/dist/eslint/rules/valid.js +45 -0
- package/dist/generated-signature.d.ts +12 -0
- package/dist/generated-signature.js +7 -0
- package/dist/lib/comment-style.d.ts +19 -0
- package/dist/lib/comment-style.js +132 -0
- package/dist/lib/errors.d.ts +44 -0
- package/dist/lib/errors.js +36 -0
- package/dist/lib/manifest.d.ts +63 -0
- package/dist/lib/manifest.js +88 -0
- package/dist/lib/merge.d.ts +57 -0
- package/dist/lib/merge.js +35 -0
- package/dist/lib/sections.d.ts +42 -0
- package/dist/lib/sections.js +84 -0
- package/dist/lib/signature.d.ts +55 -0
- package/dist/lib/signature.js +85 -0
- package/package.json +62 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jacob Ley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,269 @@
|
|
|
1
|
-
|
|
1
|
+
<div style="text-align:center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# generated-signature
|
|
4
|
+
Sign generated files so hand edits are detected, while keeping manual sections editable.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/generated-signature)
|
|
7
|
+
[](https://github.com/JacobLey/leyman/blob/main/tools/generated-signature/generated-signature/LICENSE)
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
Guides and the full reference are on the [docs site](https://jacobley.github.io/leyman/generated-signature/).
|
|
12
|
+
|
|
13
|
+
- **Offline check**: `verify()` needs only the file, not the generator, so it is cheap enough for a lint rule or a pre-commit hook.
|
|
14
|
+
- **Manual sections**: mark regions a person may edit. Their content is left out of the signature and carried over when the file is regenerated.
|
|
15
|
+
- **Any text format**: markers own whole lines, so verification doesn't need to know the comment syntax. Helpers render markers for common file types.
|
|
16
|
+
- **ESLint rule**: `generated-signature/eslint` reports hand-edited files in the editor.
|
|
17
|
+
- **Zero dependencies**: only `node:crypto`. `eslint` is an optional peer, for the ESLint entry point only.
|
|
18
|
+
|
|
19
|
+
Files that can't hold comments (like JSON) are recorded in a [manifest](#manifest) instead. Not a freshness check: a valid signature means nobody edited the file, not that regenerating it would produce the same output.
|
|
20
|
+
|
|
21
|
+
## Contents
|
|
22
|
+
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Example](#example)
|
|
25
|
+
- [Usage](#usage)
|
|
26
|
+
- [File format](#file-format)
|
|
27
|
+
- [API](#api)
|
|
28
|
+
- [sign](#signtext-options)
|
|
29
|
+
- [signatureToken](#signaturetoken)
|
|
30
|
+
- [verify](#verifytext)
|
|
31
|
+
- [manualSection](#manualsectionoptions)
|
|
32
|
+
- [mergeSections](#mergesectionsparams)
|
|
33
|
+
- [parseSections](#parsesectionstext)
|
|
34
|
+
- [commentStyle](#commentstylefilepath)
|
|
35
|
+
- [Manifest](#manifest)
|
|
36
|
+
- [Errors](#errors)
|
|
37
|
+
- [ESLint rules](#eslint-rules)
|
|
38
|
+
- [generated-signature/valid](#generated-signaturevalid)
|
|
39
|
+
- [Compared to alternatives](#compared-to-alternatives)
|
|
40
|
+
- [Also See](#also-see)
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm i generated-signature
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Example
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { manualSection, mergeSections, sign, verify } from 'generated-signature';
|
|
52
|
+
|
|
53
|
+
const generated = `export class AutoGenerated {
|
|
54
|
+
${manualSection({ id: 'value', body: 'static readonly VALUE = 123;', indent: ' ', filePath: 'foo.ts' })}
|
|
55
|
+
|
|
56
|
+
add(input: number): number {
|
|
57
|
+
return input + AutoGenerated.VALUE;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
// First write: insert a signature line.
|
|
63
|
+
let text = sign(generated, { filePath: 'foo.ts' });
|
|
64
|
+
|
|
65
|
+
verify(text); // { status: 'valid' }
|
|
66
|
+
verify(text.replace('123', '456')); // { status: 'valid' }, inside a manual section
|
|
67
|
+
verify(text.replace('input +', 'input -')); // { status: 'invalid' }
|
|
68
|
+
|
|
69
|
+
// Regenerating: keep what people wrote in manual sections.
|
|
70
|
+
const existing = text.replace('123', '456');
|
|
71
|
+
const { text: merged, orphans } = mergeSections({ generated, existing });
|
|
72
|
+
text = sign(merged, { filePath: 'foo.ts' }); // VALUE is still 456
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
`generated-signature` is an ESM module. It must be `import`ed. To load from a CJS module, use dynamic import: `const { verify } = await import('generated-signature');`.
|
|
78
|
+
|
|
79
|
+
A generator renders its output (with `manualSection()` for editable regions), merges in the existing file's sections with `mergeSections()`, and signs the result with `sign()`. Anything can then call `verify()` on the file to find out whether someone edited it outside the manual sections.
|
|
80
|
+
|
|
81
|
+
[`populate-files`](https://www.npmjs.com/package/populate-files) is the intended writer, but the package works on plain strings and has no opinion on how files are written.
|
|
82
|
+
|
|
83
|
+
### File format
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// @generated-signature v1:3f9a0c… Generated file. Do not edit outside of manual sections.
|
|
87
|
+
export class AutoGenerated {
|
|
88
|
+
// @manual-start value
|
|
89
|
+
static readonly VALUE = 123;
|
|
90
|
+
// @manual-end value
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- **Signature token**: `@generated-signature v1:` followed by 32 lowercase hex characters. It must appear exactly once, anywhere in the file. `sign()` inserts it as the first line (after a shebang or XML declaration) unless the text already has one, so a generator with its own header can place it there with [`signatureToken()`](#signaturetoken).
|
|
95
|
+
- **Manual sections**: a line containing `@manual-start <id>` and a later line containing `@manual-end <id>`. Ids are letters, digits, `_`, `.` and `-`, unique per file. Sections do not nest. The marker must be at the start of the line or after whitespace.
|
|
96
|
+
- **Hash**: SHA-256 (first 128 bits) of the file with `\r\n` normalized to `\n`, the token's hash replaced by zeros, and every section body removed. The marker lines are hashed, so adding, removing or renaming a section is detected. `v1` versions this algorithm.
|
|
97
|
+
|
|
98
|
+
## API
|
|
99
|
+
|
|
100
|
+
### `sign(text, options?)`
|
|
101
|
+
|
|
102
|
+
Fills in the signature token's hash, inserting a signature line first if the text has none.
|
|
103
|
+
|
|
104
|
+
| Parameter | Type | Description |
|
|
105
|
+
|-----------|------|-------------|
|
|
106
|
+
| `text` | `string` | Required. Generated file content. |
|
|
107
|
+
| `options` | `object` | Required only when `text` has no token. See below. |
|
|
108
|
+
|
|
109
|
+
| Option | Type | Default | Description |
|
|
110
|
+
|--------|------|---------|-------------|
|
|
111
|
+
| `filePath` / `comment` | `string` / `CommentStyle` | — | Required. Comment syntax for the inserted line, either looked up from a file name (see [`commentStyle`](#commentstylefilepath)) or given directly. |
|
|
112
|
+
| `message` | `string` | `'Generated file. Do not edit outside of manual sections.'` | Text after the token on the inserted line. |
|
|
113
|
+
|
|
114
|
+
**Returns** `string`: the signed text. Signing is idempotent, and preserves `\r\n` line breaks.
|
|
115
|
+
|
|
116
|
+
**Throws** `GeneratedSignatureMalformedError` for a duplicate or unsupported token, or malformed sections. `GeneratedSignatureCommentStyleError` when a line must be inserted and `filePath` has no known comment syntax. `TypeError` when a line must be inserted and no options are given.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
### `signatureToken()`
|
|
121
|
+
|
|
122
|
+
**Returns** `string`: a token with a placeholder hash. Generators with their own header put it there, and `sign()` fills in the hash instead of inserting a line:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
sign(`// AUTO-BARREL ${signatureToken()}\nexport * from './foo.js';\n`);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
### `verify(text)`
|
|
131
|
+
|
|
132
|
+
Checks the file's signature.
|
|
133
|
+
|
|
134
|
+
**Returns** `VerifyResult`, discriminated on `status`:
|
|
135
|
+
|
|
136
|
+
| `status` | Meaning |
|
|
137
|
+
|----------|---------|
|
|
138
|
+
| `'valid'` | Signed, and unchanged outside manual sections. |
|
|
139
|
+
| `'invalid'` | Signed, but changed outside manual sections. |
|
|
140
|
+
| `'unsigned'` | No signature token. |
|
|
141
|
+
| `'malformed'` | Has an `error: GeneratedSignatureMalformedError` explaining what can't be read. |
|
|
142
|
+
|
|
143
|
+
Never throws for any input string.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### `manualSection(options)`
|
|
148
|
+
|
|
149
|
+
Renders a manual section for a generator to embed.
|
|
150
|
+
|
|
151
|
+
| Property | Type | Default | Description |
|
|
152
|
+
|----------|------|---------|-------------|
|
|
153
|
+
| `id` | `string` | — | Required. Section id. |
|
|
154
|
+
| `body` | `string` | `''` | Default content, until someone edits it. A trailing line break is ignored. |
|
|
155
|
+
| `indent` | `string` | `''` | Prefixed to both markers and every non-blank body line. |
|
|
156
|
+
| `filePath` / `comment` | | — | Required. Comment syntax, as for [`sign`](#signtext-options). |
|
|
157
|
+
|
|
158
|
+
**Returns** `string`: the start marker, body and end marker lines, without a trailing line break.
|
|
159
|
+
|
|
160
|
+
**Throws** `GeneratedSignatureSectionIdError` for an invalid id. `GeneratedSignatureCommentStyleError` for an unknown file type.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
### `mergeSections(params)`
|
|
165
|
+
|
|
166
|
+
Carries manual sections from an existing file into freshly generated text, matched by id.
|
|
167
|
+
|
|
168
|
+
| Property | Type | Description |
|
|
169
|
+
|----------|------|-------------|
|
|
170
|
+
| `generated` | `string` | Required. New generator output, with default section bodies. |
|
|
171
|
+
| `existing` | `string` | Required. Current file content. |
|
|
172
|
+
|
|
173
|
+
**Returns** `{ text: string, orphans: readonly string[] }`. `text` is `generated` with each section's body taken from `existing` when it has the same id. `orphans` lists ids that only `existing` has and whose body isn't blank: dropping them would lose someone's edits, so callers should usually fail on them.
|
|
174
|
+
|
|
175
|
+
**Throws** `GeneratedSignatureMalformedError` if either text's sections are malformed.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
### `parseSections(text)`
|
|
180
|
+
|
|
181
|
+
**Returns** `ReadonlyMap<string, ManualSection>` in file order, where `ManualSection` is `{ id, body, startLine, endLine }`. `body` is everything between the marker lines, including its final line break. Lines are 1-indexed.
|
|
182
|
+
|
|
183
|
+
**Throws** `GeneratedSignatureMalformedError` if markers are unbalanced, nested, duplicated or have an invalid id.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
### `commentStyle(filePath)`
|
|
188
|
+
|
|
189
|
+
**Returns** the line comment syntax for a file, as `CommentStyle` (`{ start: string, end?: string }`), looked up by extension or file name (`Dockerfile`, `.gitignore`, …). `null` if unknown, or for formats without comments like `.json`.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
### Manifest
|
|
194
|
+
|
|
195
|
+
A JSON file recording generated files that don't carry their own token, e.g. JSON. [`populate-files`](https://www.npmjs.com/package/populate-files) writes it; these helpers read and check it.
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{
|
|
199
|
+
"version": 1,
|
|
200
|
+
"files": {
|
|
201
|
+
"schema.json": { "hash": "v1:…" },
|
|
202
|
+
"types.ts": { "signature": "inline", "inputs": { "generator": "genever@1.4.0" } }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- Paths are relative to the manifest's directory, with `/` separators.
|
|
208
|
+
- `hash` is the [`contentHash`](#contenthashcontent) of the file. `signature: "inline"` means the file carries its own token, which is checked instead.
|
|
209
|
+
- `inputs` is optional: hashes of what produced the file, written and checked by the generator. This package doesn't interpret it.
|
|
210
|
+
|
|
211
|
+
| Function | Description |
|
|
212
|
+
|----------|-------------|
|
|
213
|
+
| <a id="contenthashcontent"></a>`contentHash(content: string \| Uint8Array): string` | `v1:` and 32 hex characters of SHA-256. `\r\n` counts as `\n`, also in bytes. Manual sections are **not** excluded, so a file with manual sections should be signed inline. |
|
|
214
|
+
| `verifyManifestEntry(content, entry): VerifyResult` | `valid` or `invalid` for a `hash` entry; [`verify()`](#verifytext)'s result for an `inline` entry. |
|
|
215
|
+
| `parseManifest(text): Manifest` | Parses and validates. Throws `GeneratedSignatureManifestError` if the text is not a valid manifest. |
|
|
216
|
+
| `serializeManifest(files): string` | Manifest text, with paths and inputs sorted so the output doesn't depend on write order. |
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
### Errors
|
|
221
|
+
|
|
222
|
+
All errors extend the abstract `GeneratedSignatureError`.
|
|
223
|
+
|
|
224
|
+
| Error | Properties | Thrown when |
|
|
225
|
+
|-------|------------|-------------|
|
|
226
|
+
| `GeneratedSignatureMalformedError` | `reason: MalformedReason`, `line: number` | A token or manual section can't be interpreted. `reason` is one of `'duplicate-section'`, `'duplicate-signature'`, `'invalid-section-id'`, `'nested-section'`, `'unclosed-section'`, `'unopened-section'`, `'unsupported-version'`. |
|
|
227
|
+
| `GeneratedSignatureCommentStyleError` | `filePath: string` | No comment syntax is known for a file. |
|
|
228
|
+
| `GeneratedSignatureSectionIdError` | `id: string` | `manualSection()` gets an invalid id. |
|
|
229
|
+
| `GeneratedSignatureManifestError` | | `parseManifest()` gets text that is not a valid manifest. |
|
|
230
|
+
|
|
231
|
+
## ESLint rules
|
|
232
|
+
|
|
233
|
+
The plugin is a separate entry point, so importing `generated-signature` never loads it, and `eslint` is only needed when you use it:
|
|
234
|
+
|
|
235
|
+
```js
|
|
236
|
+
// eslint.config.js
|
|
237
|
+
import generatedSignature from 'generated-signature/eslint';
|
|
238
|
+
|
|
239
|
+
export default [generatedSignature.configs.recommended];
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`configs.recommended` registers the plugin (namespace `generated-signature`) and turns on every rule below as an error.
|
|
243
|
+
|
|
244
|
+
### `generated-signature/valid`
|
|
245
|
+
|
|
246
|
+
Reports a file whose signature doesn't match (it was edited outside its manual sections), or whose token or manual sections are malformed. Unsigned files are ignored. There is no autofix: re-signing would just approve the edit. Regenerate the file instead, or move the change into a manual section.
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
// @generated-signature v1:… Generated file. Do not edit outside of manual sections.
|
|
250
|
+
export const generated = 2; // ❌ edited by hand
|
|
251
|
+
// @manual-start custom
|
|
252
|
+
export const custom = 2; // ✅ manual sections may be edited
|
|
253
|
+
// @manual-end custom
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
ESLint only lints the files it is configured for. To check every signed file whatever its type, call [`verify()`](#verifytext) on it.
|
|
257
|
+
|
|
258
|
+
## Compared to alternatives
|
|
259
|
+
|
|
260
|
+
| | Signature | Manual sections | Language |
|
|
261
|
+
|---|---|---|---|
|
|
262
|
+
| `generated-signature` | Yes | Yes | Any text format, JS API |
|
|
263
|
+
| Meta's SignedSource / `@partially-generated` | Yes | Yes | Internal tooling, Hack/JS, not a general library |
|
|
264
|
+
| [cog](https://nedbatchelder.com/code/cog/) `--checksum` | Yes | Inverse: generated regions in a handwritten file | Python |
|
|
265
|
+
| Go's `// Code generated ... DO NOT EDIT.` | No, a convention only | No | Go |
|
|
266
|
+
|
|
267
|
+
## Also See
|
|
268
|
+
|
|
269
|
+
- [`populate-files`](https://www.npmjs.com/package/populate-files): writes generated files and keeps them in sync
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ESLint, Linter } from 'eslint';
|
|
2
|
+
import { valid } from './rules/valid.js';
|
|
3
|
+
declare const NAMESPACE = "generated-signature";
|
|
4
|
+
interface GeneratedSignaturePlugin extends ESLint.Plugin {
|
|
5
|
+
meta: {
|
|
6
|
+
name: string;
|
|
7
|
+
namespace: typeof NAMESPACE;
|
|
8
|
+
};
|
|
9
|
+
rules: {
|
|
10
|
+
valid: typeof valid;
|
|
11
|
+
};
|
|
12
|
+
configs: {
|
|
13
|
+
recommended: Linter.Config;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* ESLint plugin, from the `generated-signature/eslint` entry point so the main entry point never
|
|
18
|
+
* loads it. `eslint` is only used for types.
|
|
19
|
+
*/
|
|
20
|
+
declare const plugin: GeneratedSignaturePlugin;
|
|
21
|
+
export default plugin;
|
|
22
|
+
//# sourceMappingURL=plugin.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { valid } from './rules/valid.js';
|
|
2
|
+
const NAMESPACE = 'generated-signature';
|
|
3
|
+
const plugin = {
|
|
4
|
+
meta: {
|
|
5
|
+
name: 'generated-signature/eslint',
|
|
6
|
+
namespace: NAMESPACE
|
|
7
|
+
},
|
|
8
|
+
rules: {
|
|
9
|
+
valid
|
|
10
|
+
},
|
|
11
|
+
configs: {
|
|
12
|
+
recommended: {
|
|
13
|
+
rules: {
|
|
14
|
+
[`${NAMESPACE}/valid`]: 'error'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
plugin.configs.recommended.plugins = {
|
|
20
|
+
[NAMESPACE]: plugin
|
|
21
|
+
};
|
|
22
|
+
export default plugin;
|
|
23
|
+
|
|
24
|
+
//# sourceMappingURL=plugin.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { verify } from '../../lib/signature.js';
|
|
2
|
+
export const valid = {
|
|
3
|
+
meta: {
|
|
4
|
+
type: 'problem',
|
|
5
|
+
docs: {
|
|
6
|
+
description: 'Disallow editing generated files outside of their manual sections',
|
|
7
|
+
url: 'https://jacobley.github.io/leyman/generated-signature/guides/eslint#generated-signature-valid'
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
invalid: 'Generated file was edited outside of its manual sections. Regenerate it instead.',
|
|
11
|
+
malformed: 'Generated file is malformed: {{ reason }}.'
|
|
12
|
+
},
|
|
13
|
+
schema: []
|
|
14
|
+
},
|
|
15
|
+
create: (context)=>({
|
|
16
|
+
Program: ()=>{
|
|
17
|
+
const { sourceCode } = context;
|
|
18
|
+
const result = verify(sourceCode.text);
|
|
19
|
+
if (result.status === 'invalid') {
|
|
20
|
+
const index = Math.max(sourceCode.text.indexOf('@generated-signature'), 0);
|
|
21
|
+
const start = sourceCode.getLocFromIndex(index);
|
|
22
|
+
context.report({
|
|
23
|
+
loc: {
|
|
24
|
+
start,
|
|
25
|
+
end: start
|
|
26
|
+
},
|
|
27
|
+
messageId: 'invalid'
|
|
28
|
+
});
|
|
29
|
+
} else if (result.status === 'malformed') {
|
|
30
|
+
context.report({
|
|
31
|
+
loc: {
|
|
32
|
+
line: result.error.line,
|
|
33
|
+
column: 0
|
|
34
|
+
},
|
|
35
|
+
messageId: 'malformed',
|
|
36
|
+
data: {
|
|
37
|
+
reason: result.error.reason
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=valid.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type { CommentOptions, CommentStyle } from './lib/comment-style.js';
|
|
2
|
+
export type { MalformedReason } from './lib/errors.js';
|
|
3
|
+
export type { Manifest, ManifestEntry, ManifestInputs } from './lib/manifest.js';
|
|
4
|
+
export type { ManualSectionOptions, MergeResult } from './lib/merge.js';
|
|
5
|
+
export type { ManualSection } from './lib/sections.js';
|
|
6
|
+
export type { SignOptions, VerifyResult } from './lib/signature.js';
|
|
7
|
+
export { commentStyle } from './lib/comment-style.js';
|
|
8
|
+
export { GeneratedSignatureCommentStyleError, GeneratedSignatureError, GeneratedSignatureMalformedError, GeneratedSignatureManifestError, GeneratedSignatureSectionIdError, } from './lib/errors.js';
|
|
9
|
+
export { contentHash, parseManifest, serializeManifest, verifyManifestEntry, } from './lib/manifest.js';
|
|
10
|
+
export { manualSection, mergeSections, parseSections } from './lib/merge.js';
|
|
11
|
+
export { sign, signatureToken, verify } from './lib/signature.js';
|
|
12
|
+
//# sourceMappingURL=generated-signature.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { commentStyle } from './lib/comment-style.js';
|
|
2
|
+
export { GeneratedSignatureCommentStyleError, GeneratedSignatureError, GeneratedSignatureMalformedError, GeneratedSignatureManifestError, GeneratedSignatureSectionIdError } from './lib/errors.js';
|
|
3
|
+
export { contentHash, parseManifest, serializeManifest, verifyManifestEntry } from './lib/manifest.js';
|
|
4
|
+
export { manualSection, mergeSections, parseSections } from './lib/merge.js';
|
|
5
|
+
export { sign, signatureToken, verify } from './lib/signature.js';
|
|
6
|
+
|
|
7
|
+
//# sourceMappingURL=generated-signature.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface CommentStyle {
|
|
2
|
+
readonly start: string;
|
|
3
|
+
readonly end?: string;
|
|
4
|
+
}
|
|
5
|
+
export type CommentOptions = {
|
|
6
|
+
readonly comment: CommentStyle;
|
|
7
|
+
} | {
|
|
8
|
+
readonly filePath: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Look up the line comment syntax for a file, based on its name.
|
|
12
|
+
*
|
|
13
|
+
* @param filePath - path (or name) of the file
|
|
14
|
+
* @returns comment style, or `null` if unknown (or the format has no comments, like JSON)
|
|
15
|
+
*/
|
|
16
|
+
export declare const commentStyle: (filePath: string) => CommentStyle | null;
|
|
17
|
+
export declare const resolveCommentStyle: (options: CommentOptions) => CommentStyle;
|
|
18
|
+
export declare const renderComment: (style: CommentStyle, text: string) => string;
|
|
19
|
+
//# sourceMappingURL=comment-style.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import Path from 'node:path';
|
|
2
|
+
import { GeneratedSignatureCommentStyleError } from './errors.js';
|
|
3
|
+
const slashes = {
|
|
4
|
+
start: '//'
|
|
5
|
+
};
|
|
6
|
+
const hash = {
|
|
7
|
+
start: '#'
|
|
8
|
+
};
|
|
9
|
+
const block = {
|
|
10
|
+
start: '/*',
|
|
11
|
+
end: '*/'
|
|
12
|
+
};
|
|
13
|
+
const markup = {
|
|
14
|
+
start: '<!--',
|
|
15
|
+
end: '-->'
|
|
16
|
+
};
|
|
17
|
+
const dashes = {
|
|
18
|
+
start: '--'
|
|
19
|
+
};
|
|
20
|
+
const byExtension = new Map([
|
|
21
|
+
...[
|
|
22
|
+
'.c',
|
|
23
|
+
'.cc',
|
|
24
|
+
'.cjs',
|
|
25
|
+
'.cpp',
|
|
26
|
+
'.cs',
|
|
27
|
+
'.cts',
|
|
28
|
+
'.go',
|
|
29
|
+
'.h',
|
|
30
|
+
'.hpp',
|
|
31
|
+
'.java',
|
|
32
|
+
'.js',
|
|
33
|
+
'.json5',
|
|
34
|
+
'.jsonc',
|
|
35
|
+
'.jsx',
|
|
36
|
+
'.kt',
|
|
37
|
+
'.less',
|
|
38
|
+
'.mjs',
|
|
39
|
+
'.mts',
|
|
40
|
+
'.rs',
|
|
41
|
+
'.scss',
|
|
42
|
+
'.swift',
|
|
43
|
+
'.ts',
|
|
44
|
+
'.tsx'
|
|
45
|
+
].map((ext)=>[
|
|
46
|
+
ext,
|
|
47
|
+
slashes
|
|
48
|
+
]),
|
|
49
|
+
...[
|
|
50
|
+
'.bash',
|
|
51
|
+
'.cfg',
|
|
52
|
+
'.conf',
|
|
53
|
+
'.env',
|
|
54
|
+
'.ini',
|
|
55
|
+
'.pl',
|
|
56
|
+
'.properties',
|
|
57
|
+
'.py',
|
|
58
|
+
'.r',
|
|
59
|
+
'.rb',
|
|
60
|
+
'.sh',
|
|
61
|
+
'.toml',
|
|
62
|
+
'.yaml',
|
|
63
|
+
'.yml',
|
|
64
|
+
'.zsh'
|
|
65
|
+
].map((ext)=>[
|
|
66
|
+
ext,
|
|
67
|
+
hash
|
|
68
|
+
]),
|
|
69
|
+
[
|
|
70
|
+
'.css',
|
|
71
|
+
block
|
|
72
|
+
],
|
|
73
|
+
...[
|
|
74
|
+
'.htm',
|
|
75
|
+
'.html',
|
|
76
|
+
'.md',
|
|
77
|
+
'.mdx',
|
|
78
|
+
'.svg',
|
|
79
|
+
'.vue',
|
|
80
|
+
'.xml'
|
|
81
|
+
].map((ext)=>[
|
|
82
|
+
ext,
|
|
83
|
+
markup
|
|
84
|
+
]),
|
|
85
|
+
...[
|
|
86
|
+
'.hs',
|
|
87
|
+
'.lua',
|
|
88
|
+
'.sql'
|
|
89
|
+
].map((ext)=>[
|
|
90
|
+
ext,
|
|
91
|
+
dashes
|
|
92
|
+
])
|
|
93
|
+
]);
|
|
94
|
+
const byFileName = new Map([
|
|
95
|
+
[
|
|
96
|
+
'.gitattributes',
|
|
97
|
+
hash
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
'.gitignore',
|
|
101
|
+
hash
|
|
102
|
+
],
|
|
103
|
+
[
|
|
104
|
+
'.npmignore',
|
|
105
|
+
hash
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
'Dockerfile',
|
|
109
|
+
hash
|
|
110
|
+
],
|
|
111
|
+
[
|
|
112
|
+
'Makefile',
|
|
113
|
+
hash
|
|
114
|
+
]
|
|
115
|
+
]);
|
|
116
|
+
export const commentStyle = (filePath)=>{
|
|
117
|
+
const base = Path.basename(filePath);
|
|
118
|
+
return byFileName.get(base) ?? byExtension.get(Path.extname(base).toLowerCase()) ?? byFileName.get(base.split('.', 1)[0]) ?? null;
|
|
119
|
+
};
|
|
120
|
+
export const resolveCommentStyle = (options)=>{
|
|
121
|
+
if ('comment' in options) {
|
|
122
|
+
return options.comment;
|
|
123
|
+
}
|
|
124
|
+
const style = commentStyle(options.filePath);
|
|
125
|
+
if (style === null) {
|
|
126
|
+
throw new GeneratedSignatureCommentStyleError(options.filePath);
|
|
127
|
+
}
|
|
128
|
+
return style;
|
|
129
|
+
};
|
|
130
|
+
export const renderComment = (style, text)=>style.end === undefined ? `${style.start} ${text}` : `${style.start} ${text} ${style.end}`;
|
|
131
|
+
|
|
132
|
+
//# sourceMappingURL=comment-style.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class of every error thrown by this package.
|
|
3
|
+
*
|
|
4
|
+
* All instances of this error will be of a subclass with a more specific failure reason.
|
|
5
|
+
*/
|
|
6
|
+
export declare abstract class GeneratedSignatureError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export type MalformedReason = 'duplicate-section' | 'duplicate-signature' | 'invalid-section-id' | 'nested-section' | 'unclosed-section' | 'unopened-section' | 'unsupported-version';
|
|
9
|
+
/**
|
|
10
|
+
* The file's signature token or manual section markers can't be interpreted.
|
|
11
|
+
*/
|
|
12
|
+
export declare class GeneratedSignatureMalformedError extends GeneratedSignatureError {
|
|
13
|
+
readonly reason: MalformedReason;
|
|
14
|
+
/**
|
|
15
|
+
* 1-indexed line of the offending marker or token.
|
|
16
|
+
*/
|
|
17
|
+
readonly line: number;
|
|
18
|
+
constructor({ reason, line, detail, }: {
|
|
19
|
+
reason: MalformedReason;
|
|
20
|
+
line: number;
|
|
21
|
+
detail: string;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A signature line has to be inserted, but no comment syntax is known for the file.
|
|
26
|
+
*/
|
|
27
|
+
export declare class GeneratedSignatureCommentStyleError extends GeneratedSignatureError {
|
|
28
|
+
readonly filePath: string;
|
|
29
|
+
constructor(filePath: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A manual section id can't be used in a marker.
|
|
33
|
+
*/
|
|
34
|
+
export declare class GeneratedSignatureSectionIdError extends GeneratedSignatureError {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
constructor(id: string);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A manifest file can't be read.
|
|
40
|
+
*/
|
|
41
|
+
export declare class GeneratedSignatureManifestError extends GeneratedSignatureError {
|
|
42
|
+
constructor(detail: string, options?: ErrorOptions);
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class GeneratedSignatureError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export class GeneratedSignatureMalformedError extends GeneratedSignatureError {
|
|
4
|
+
reason;
|
|
5
|
+
line;
|
|
6
|
+
constructor({ reason, line, detail }){
|
|
7
|
+
super(`Malformed generated file (${reason}) at line ${line}: ${detail}`);
|
|
8
|
+
this.name = 'GeneratedSignatureMalformedError';
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
this.line = line;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class GeneratedSignatureCommentStyleError extends GeneratedSignatureError {
|
|
14
|
+
filePath;
|
|
15
|
+
constructor(filePath){
|
|
16
|
+
super(`No known comment syntax for ${filePath}, pass a comment style explicitly`);
|
|
17
|
+
this.name = 'GeneratedSignatureCommentStyleError';
|
|
18
|
+
this.filePath = filePath;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class GeneratedSignatureSectionIdError extends GeneratedSignatureError {
|
|
22
|
+
id;
|
|
23
|
+
constructor(id){
|
|
24
|
+
super(`Invalid manual section id "${id}", only letters, digits, "_", "." and "-" allowed`);
|
|
25
|
+
this.name = 'GeneratedSignatureSectionIdError';
|
|
26
|
+
this.id = id;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class GeneratedSignatureManifestError extends GeneratedSignatureError {
|
|
30
|
+
constructor(detail, options){
|
|
31
|
+
super(`Invalid generated-signature manifest: ${detail}`, options);
|
|
32
|
+
this.name = 'GeneratedSignatureManifestError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { VerifyResult } from './signature.js';
|
|
2
|
+
declare const VERSION = 1;
|
|
3
|
+
/**
|
|
4
|
+
* Hashes of what produced a file, e.g. `{ "generator": "genever@1.0.0", "./user.json": "v1:…" }`.
|
|
5
|
+
* Opaque to this package: the generator decides what to record, and how to check it.
|
|
6
|
+
*/
|
|
7
|
+
export type ManifestInputs = Readonly<Record<string, string>>;
|
|
8
|
+
export type ManifestEntry = {
|
|
9
|
+
/**
|
|
10
|
+
* {@link contentHash} of the file.
|
|
11
|
+
*/
|
|
12
|
+
readonly hash: string;
|
|
13
|
+
readonly inputs?: ManifestInputs;
|
|
14
|
+
} | {
|
|
15
|
+
/**
|
|
16
|
+
* The file carries its own signature token, which is checked instead of a hash.
|
|
17
|
+
*/
|
|
18
|
+
readonly signature: 'inline';
|
|
19
|
+
readonly inputs?: ManifestInputs;
|
|
20
|
+
};
|
|
21
|
+
export interface Manifest {
|
|
22
|
+
readonly version: typeof VERSION;
|
|
23
|
+
/**
|
|
24
|
+
* Entries by path relative to the manifest's directory, with `/` separators.
|
|
25
|
+
*/
|
|
26
|
+
readonly files: Readonly<Record<string, ManifestEntry>>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Hash a file's content, for a manifest entry.
|
|
30
|
+
*
|
|
31
|
+
* `\r\n` counts as `\n` (also in binary content), so a checkout with `core.autocrlf` still matches.
|
|
32
|
+
* Unlike an inline signature, manual sections are not excluded.
|
|
33
|
+
*
|
|
34
|
+
* @param content - file content
|
|
35
|
+
* @returns `v1:` followed by 32 hex characters
|
|
36
|
+
*/
|
|
37
|
+
export declare const contentHash: (content: string | Uint8Array) => string;
|
|
38
|
+
/**
|
|
39
|
+
* Check a file against its manifest entry.
|
|
40
|
+
*
|
|
41
|
+
* @param content - file content
|
|
42
|
+
* @param entry - the file's manifest entry
|
|
43
|
+
* @returns `valid` or `invalid` for a hash entry, the inline signature's result otherwise
|
|
44
|
+
*/
|
|
45
|
+
export declare const verifyManifestEntry: (content: string | Uint8Array, entry: ManifestEntry) => VerifyResult;
|
|
46
|
+
/**
|
|
47
|
+
* Parse and validate a manifest.
|
|
48
|
+
*
|
|
49
|
+
* @param text - manifest file content
|
|
50
|
+
* @returns the validated manifest object
|
|
51
|
+
* @throws {GeneratedSignatureManifestError} if the text is not a valid manifest
|
|
52
|
+
*/
|
|
53
|
+
export declare const parseManifest: (text: string) => Manifest;
|
|
54
|
+
/**
|
|
55
|
+
* Serialize a manifest deterministically: file paths and inputs are sorted, so regenerating in
|
|
56
|
+
* a different order produces the same text.
|
|
57
|
+
*
|
|
58
|
+
* @param files - entries by path relative to the manifest's directory
|
|
59
|
+
* @returns manifest file content
|
|
60
|
+
*/
|
|
61
|
+
export declare const serializeManifest: (files: Readonly<Record<string, ManifestEntry>>) => string;
|
|
62
|
+
export {};
|
|
63
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { GeneratedSignatureManifestError } from './errors.js';
|
|
3
|
+
import { verify } from './signature.js';
|
|
4
|
+
const VERSION = 1;
|
|
5
|
+
const HASH_PREFIX = 'v1:';
|
|
6
|
+
const HASH_LENGTH = 32;
|
|
7
|
+
const CR = 0x0d;
|
|
8
|
+
const LF = 0x0a;
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
const normalizeLineBreaks = (bytes)=>{
|
|
12
|
+
const result = new Uint8Array(bytes.length);
|
|
13
|
+
let length = 0;
|
|
14
|
+
for (const [i, byte] of bytes.entries()){
|
|
15
|
+
if (byte === CR && bytes[i + 1] === LF) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
result[length] = byte;
|
|
19
|
+
length++;
|
|
20
|
+
}
|
|
21
|
+
return result.subarray(0, length);
|
|
22
|
+
};
|
|
23
|
+
export const contentHash = (content)=>{
|
|
24
|
+
const bytes = typeof content === 'string' ? encoder.encode(content) : content;
|
|
25
|
+
return HASH_PREFIX + createHash('sha256').update(normalizeLineBreaks(bytes)).digest('hex').slice(0, HASH_LENGTH);
|
|
26
|
+
};
|
|
27
|
+
export const verifyManifestEntry = (content, entry)=>{
|
|
28
|
+
if ('signature' in entry) {
|
|
29
|
+
return verify(typeof content === 'string' ? content : decoder.decode(content));
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
status: contentHash(content) === entry.hash ? 'valid' : 'invalid'
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
const isStringRecord = (value)=>typeof value === 'object' && value !== null && !Array.isArray(value) && Object.values(value).every((item)=>typeof item === 'string');
|
|
36
|
+
const isEntry = (value)=>{
|
|
37
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const { hash, signature, inputs, ...rest } = value;
|
|
41
|
+
return Object.keys(rest).length === 0 && (inputs === undefined || isStringRecord(inputs)) && typeof hash === 'string' !== (signature === 'inline') && (hash === undefined || typeof hash === 'string') && (signature === undefined || signature === 'inline');
|
|
42
|
+
};
|
|
43
|
+
export const parseManifest = (text)=>{
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(text);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new GeneratedSignatureManifestError('not valid JSON', {
|
|
49
|
+
cause: err
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
53
|
+
throw new GeneratedSignatureManifestError('not an object');
|
|
54
|
+
}
|
|
55
|
+
const { version, files } = parsed;
|
|
56
|
+
if (version !== VERSION) {
|
|
57
|
+
throw new GeneratedSignatureManifestError(`unsupported version ${String(version)}`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof files !== 'object' || files === null || Array.isArray(files)) {
|
|
60
|
+
throw new GeneratedSignatureManifestError('files is not an object');
|
|
61
|
+
}
|
|
62
|
+
for (const [path, entry] of Object.entries(files)){
|
|
63
|
+
if (!isEntry(entry)) {
|
|
64
|
+
throw new GeneratedSignatureManifestError(`invalid entry for ${path}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
version,
|
|
69
|
+
files: files
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
const sortKeys = (record)=>Object.fromEntries(Object.entries(record).toSorted(([a], [b])=>a < b ? -1 : 1));
|
|
73
|
+
export const serializeManifest = (files)=>{
|
|
74
|
+
const sortedFiles = Object.fromEntries(Object.entries(sortKeys(files)).map(([path, entry])=>[
|
|
75
|
+
path,
|
|
76
|
+
entry.inputs ? {
|
|
77
|
+
...entry,
|
|
78
|
+
inputs: sortKeys(entry.inputs)
|
|
79
|
+
} : entry
|
|
80
|
+
]));
|
|
81
|
+
const manifest = {
|
|
82
|
+
version: VERSION,
|
|
83
|
+
files: sortedFiles
|
|
84
|
+
};
|
|
85
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CommentOptions } from './comment-style.js';
|
|
2
|
+
import type { ManualSection } from './sections.js';
|
|
3
|
+
/**
|
|
4
|
+
* Find the manual sections in a file.
|
|
5
|
+
*
|
|
6
|
+
* @param text - file content
|
|
7
|
+
* @returns sections by id, in file order
|
|
8
|
+
* @throws {GeneratedSignatureMalformedError} if markers are unbalanced, nested or duplicated
|
|
9
|
+
*/
|
|
10
|
+
export declare const parseSections: (text: string) => ReadonlyMap<string, ManualSection>;
|
|
11
|
+
export interface MergeResult {
|
|
12
|
+
/**
|
|
13
|
+
* Generated text, with each section body taken from the existing file when it has a section
|
|
14
|
+
* with the same id.
|
|
15
|
+
*/
|
|
16
|
+
readonly text: string;
|
|
17
|
+
/**
|
|
18
|
+
* Ids of sections in the existing file that the generated text no longer has, and whose
|
|
19
|
+
* body isn't blank. Dropping them would lose someone's edits.
|
|
20
|
+
*/
|
|
21
|
+
readonly orphans: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Carry manual edits from an existing file over into freshly generated text.
|
|
25
|
+
*
|
|
26
|
+
* Sections are matched by id, not position, so the generator can add, remove or reorder them.
|
|
27
|
+
*
|
|
28
|
+
* @param params - texts to merge
|
|
29
|
+
* @param params.generated - new generator output, with default section bodies
|
|
30
|
+
* @param params.existing - current file content
|
|
31
|
+
* @returns merged text and orphaned section ids
|
|
32
|
+
* @throws {GeneratedSignatureMalformedError} if either text's sections are malformed
|
|
33
|
+
*/
|
|
34
|
+
export declare const mergeSections: ({ generated, existing, }: {
|
|
35
|
+
generated: string;
|
|
36
|
+
existing: string;
|
|
37
|
+
}) => MergeResult;
|
|
38
|
+
export type ManualSectionOptions = CommentOptions & {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
/**
|
|
41
|
+
* Default content, used until someone edits it.
|
|
42
|
+
*/
|
|
43
|
+
readonly body?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Prefixed to the markers and each non-blank body line.
|
|
46
|
+
*/
|
|
47
|
+
readonly indent?: string;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Render a manual section, for a generator to embed in its output.
|
|
51
|
+
*
|
|
52
|
+
* @param options - section id, default body, and comment syntax
|
|
53
|
+
* @returns marker lines around the body, without a trailing line break
|
|
54
|
+
* @throws {GeneratedSignatureSectionIdError} if the id is not valid
|
|
55
|
+
*/
|
|
56
|
+
export declare const manualSection: ({ id, body, indent, ...options }: ManualSectionOptions) => string;
|
|
57
|
+
//# sourceMappingURL=merge.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { renderComment, resolveCommentStyle } from './comment-style.js';
|
|
2
|
+
import { GeneratedSignatureSectionIdError } from './errors.js';
|
|
3
|
+
import { isValidSectionId, replaceBodies, scanSections } from './sections.js';
|
|
4
|
+
export const parseSections = (text)=>new Map(scanSections(text).map(({ id, body, startLine, endLine })=>[
|
|
5
|
+
id,
|
|
6
|
+
{
|
|
7
|
+
id,
|
|
8
|
+
body,
|
|
9
|
+
startLine,
|
|
10
|
+
endLine
|
|
11
|
+
}
|
|
12
|
+
]));
|
|
13
|
+
export const mergeSections = ({ generated, existing })=>{
|
|
14
|
+
const existingSections = parseSections(existing);
|
|
15
|
+
const generatedSections = scanSections(generated);
|
|
16
|
+
const generatedIds = new Set(generatedSections.map(({ id })=>id));
|
|
17
|
+
return {
|
|
18
|
+
text: replaceBodies(generated, generatedSections, (section)=>existingSections.get(section.id)?.body ?? section.body),
|
|
19
|
+
orphans: existingSections.values().filter(({ id, body })=>!generatedIds.has(id) && body.trim() !== '').map(({ id })=>id).toArray()
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export const manualSection = ({ id, body = '', indent = '', ...options })=>{
|
|
23
|
+
if (!isValidSectionId(id)) {
|
|
24
|
+
throw new GeneratedSignatureSectionIdError(id);
|
|
25
|
+
}
|
|
26
|
+
const style = resolveCommentStyle(options);
|
|
27
|
+
const lines = body === '' ? [] : body.replace(/\n$/u, '').split('\n');
|
|
28
|
+
return [
|
|
29
|
+
renderComment(style, `@manual-start ${id}`),
|
|
30
|
+
...lines,
|
|
31
|
+
renderComment(style, `@manual-end ${id}`)
|
|
32
|
+
].map((line)=>line.trim() === '' ? line : indent + line).join('\n');
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
//# sourceMappingURL=merge.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface ManualSection {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
/**
|
|
4
|
+
* Everything between the marker lines, including the final line break (empty if none).
|
|
5
|
+
*/
|
|
6
|
+
readonly body: string;
|
|
7
|
+
/**
|
|
8
|
+
* 1-indexed line of the start marker.
|
|
9
|
+
*/
|
|
10
|
+
readonly startLine: number;
|
|
11
|
+
/**
|
|
12
|
+
* 1-indexed line of the end marker.
|
|
13
|
+
*/
|
|
14
|
+
readonly endLine: number;
|
|
15
|
+
}
|
|
16
|
+
export interface ScannedSection extends ManualSection {
|
|
17
|
+
/**
|
|
18
|
+
* Offsets into the scanned text, `body === text.slice(bodyStart, bodyEnd)`.
|
|
19
|
+
*/
|
|
20
|
+
readonly bodyStart: number;
|
|
21
|
+
readonly bodyEnd: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const isValidSectionId: (id: string) => boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Find all manual sections in a text. Markers own their whole line, so the comment syntax
|
|
26
|
+
* around them doesn't matter.
|
|
27
|
+
*
|
|
28
|
+
* @param text - file content
|
|
29
|
+
* @returns sections, in file order
|
|
30
|
+
* @throws {GeneratedSignatureMalformedError} if markers are unbalanced, nested or duplicated
|
|
31
|
+
*/
|
|
32
|
+
export declare const scanSections: (text: string) => ScannedSection[];
|
|
33
|
+
/**
|
|
34
|
+
* Replace the body of each section, working backwards so earlier offsets stay valid.
|
|
35
|
+
*
|
|
36
|
+
* @param text - text the sections were scanned from
|
|
37
|
+
* @param sections - scanned sections of `text`
|
|
38
|
+
* @param getBody - new body for a section
|
|
39
|
+
* @returns text with bodies replaced
|
|
40
|
+
*/
|
|
41
|
+
export declare const replaceBodies: (text: string, sections: readonly ScannedSection[], getBody: (section: ScannedSection) => string) => string;
|
|
42
|
+
//# sourceMappingURL=sections.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { GeneratedSignatureMalformedError } from './errors.js';
|
|
2
|
+
const markerPattern = /(?:^|\s)@manual-(?<kind>start|end)\b(?:\s+(?<id>\S+))?/u;
|
|
3
|
+
const idPattern = /^[\w.-]+$/u;
|
|
4
|
+
export const isValidSectionId = (id)=>idPattern.test(id);
|
|
5
|
+
export const scanSections = (text)=>{
|
|
6
|
+
const sections = [];
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
let open = null;
|
|
9
|
+
let lineStart = 0;
|
|
10
|
+
let lineNumber = 0;
|
|
11
|
+
while(lineStart < text.length){
|
|
12
|
+
lineNumber++;
|
|
13
|
+
const newline = text.indexOf('\n', lineStart);
|
|
14
|
+
const lineEnd = newline === -1 ? text.length : newline + 1;
|
|
15
|
+
const match = markerPattern.exec(text.slice(lineStart, lineEnd));
|
|
16
|
+
if (match) {
|
|
17
|
+
const { kind, id = '' } = match.groups;
|
|
18
|
+
if (!isValidSectionId(id)) {
|
|
19
|
+
throw new GeneratedSignatureMalformedError({
|
|
20
|
+
reason: 'invalid-section-id',
|
|
21
|
+
line: lineNumber,
|
|
22
|
+
detail: `"${id}" must match ${idPattern}`
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (kind === 'start') {
|
|
26
|
+
if (open) {
|
|
27
|
+
throw new GeneratedSignatureMalformedError({
|
|
28
|
+
reason: 'nested-section',
|
|
29
|
+
line: lineNumber,
|
|
30
|
+
detail: `"${id}" starts inside "${open.id}"`
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (seen.has(id)) {
|
|
34
|
+
throw new GeneratedSignatureMalformedError({
|
|
35
|
+
reason: 'duplicate-section',
|
|
36
|
+
line: lineNumber,
|
|
37
|
+
detail: `"${id}" is already defined`
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
open = {
|
|
41
|
+
id,
|
|
42
|
+
line: lineNumber,
|
|
43
|
+
bodyStart: lineEnd
|
|
44
|
+
};
|
|
45
|
+
} else {
|
|
46
|
+
if (open?.id !== id) {
|
|
47
|
+
throw new GeneratedSignatureMalformedError({
|
|
48
|
+
reason: 'unopened-section',
|
|
49
|
+
line: lineNumber,
|
|
50
|
+
detail: open ? `"${id}" ends while "${open.id}" is open` : `"${id}" ends without a start`
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
sections.push({
|
|
54
|
+
id,
|
|
55
|
+
body: text.slice(open.bodyStart, lineStart),
|
|
56
|
+
startLine: open.line,
|
|
57
|
+
endLine: lineNumber,
|
|
58
|
+
bodyStart: open.bodyStart,
|
|
59
|
+
bodyEnd: lineStart
|
|
60
|
+
});
|
|
61
|
+
seen.add(id);
|
|
62
|
+
open = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
lineStart = lineEnd;
|
|
66
|
+
}
|
|
67
|
+
if (open) {
|
|
68
|
+
throw new GeneratedSignatureMalformedError({
|
|
69
|
+
reason: 'unclosed-section',
|
|
70
|
+
line: open.line,
|
|
71
|
+
detail: `"${open.id}" has no end`
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return sections;
|
|
75
|
+
};
|
|
76
|
+
export const replaceBodies = (text, sections, getBody)=>{
|
|
77
|
+
let result = text;
|
|
78
|
+
for (const section of sections.toReversed()){
|
|
79
|
+
result = result.slice(0, section.bodyStart) + getBody(section) + result.slice(section.bodyEnd);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
//# sourceMappingURL=sections.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CommentOptions } from './comment-style.js';
|
|
2
|
+
import { GeneratedSignatureMalformedError } from './errors.js';
|
|
3
|
+
export type SignOptions = CommentOptions & {
|
|
4
|
+
/**
|
|
5
|
+
* Text after the token in an inserted signature line.
|
|
6
|
+
*/
|
|
7
|
+
readonly message?: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* An unsigned signature token, for generators that place it themselves (e.g. inside their own
|
|
11
|
+
* header comment). `sign()` fills in its hash.
|
|
12
|
+
*
|
|
13
|
+
* @returns token with a placeholder hash
|
|
14
|
+
*/
|
|
15
|
+
export declare const signatureToken: () => string;
|
|
16
|
+
/**
|
|
17
|
+
* Sign a generated file.
|
|
18
|
+
*
|
|
19
|
+
* If the text already contains a signature token (e.g. a previous signature, or one the
|
|
20
|
+
* generator placed itself), its hash is replaced. Otherwise a signature line is inserted at the
|
|
21
|
+
* top of the file, using the comment syntax from `options`.
|
|
22
|
+
*
|
|
23
|
+
* @param text - generated file content
|
|
24
|
+
* @param options - comment syntax and message, required only if the text has no token yet
|
|
25
|
+
* @returns signed text
|
|
26
|
+
* @throws {GeneratedSignatureMalformedError} if the token or manual sections are malformed
|
|
27
|
+
* @throws {GeneratedSignatureCommentStyleError} if a line must be inserted for an unknown file type
|
|
28
|
+
*/
|
|
29
|
+
export declare const sign: (text: string, options?: SignOptions) => string;
|
|
30
|
+
export type VerifyResult = {
|
|
31
|
+
/**
|
|
32
|
+
* The file has no signature token.
|
|
33
|
+
*/
|
|
34
|
+
readonly status: 'unsigned';
|
|
35
|
+
} | {
|
|
36
|
+
/**
|
|
37
|
+
* The token's hash does not match, so content outside manual sections was edited.
|
|
38
|
+
*/
|
|
39
|
+
readonly status: 'invalid';
|
|
40
|
+
} | {
|
|
41
|
+
readonly status: 'malformed';
|
|
42
|
+
readonly error: GeneratedSignatureMalformedError;
|
|
43
|
+
} | {
|
|
44
|
+
readonly status: 'valid';
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Check a file's signature. Only needs the file itself, not the generator that wrote it.
|
|
48
|
+
*
|
|
49
|
+
* Does not check that the file is up to date with its generator's inputs.
|
|
50
|
+
*
|
|
51
|
+
* @param text - file content
|
|
52
|
+
* @returns verification status
|
|
53
|
+
*/
|
|
54
|
+
export declare const verify: (text: string) => VerifyResult;
|
|
55
|
+
//# sourceMappingURL=signature.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { renderComment, resolveCommentStyle } from './comment-style.js';
|
|
3
|
+
import { GeneratedSignatureMalformedError } from './errors.js';
|
|
4
|
+
import { replaceBodies, scanSections } from './sections.js';
|
|
5
|
+
const VERSION = '1';
|
|
6
|
+
const HASH_LENGTH = 32;
|
|
7
|
+
const PLACEHOLDER = '0'.repeat(HASH_LENGTH);
|
|
8
|
+
const TOKEN_NAME = '@generated-signature';
|
|
9
|
+
const tokenPattern = new RegExp(String.raw`${TOKEN_NAME} v(?<version>\d+):(?<hash>[\da-f]{${HASH_LENGTH}})`, 'gu');
|
|
10
|
+
const DEFAULT_MESSAGE = 'Generated file. Do not edit outside of manual sections.';
|
|
11
|
+
const lineOf = (text, offset)=>text.slice(0, offset).split('\n').length;
|
|
12
|
+
const findToken = (text)=>{
|
|
13
|
+
const [first, second] = text.matchAll(tokenPattern);
|
|
14
|
+
if (!first) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (second) {
|
|
18
|
+
throw new GeneratedSignatureMalformedError({
|
|
19
|
+
reason: 'duplicate-signature',
|
|
20
|
+
line: lineOf(text, second.index),
|
|
21
|
+
detail: `signature already defined at line ${lineOf(text, first.index)}`
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const { version = '', hash = '' } = first.groups;
|
|
25
|
+
if (version !== VERSION) {
|
|
26
|
+
throw new GeneratedSignatureMalformedError({
|
|
27
|
+
reason: 'unsupported-version',
|
|
28
|
+
line: lineOf(text, first.index),
|
|
29
|
+
detail: `v${version} is not supported, expected v${VERSION}`
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
hash,
|
|
34
|
+
hashStart: first.index + first[0].length - HASH_LENGTH
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
const replaceHash = (text, token, hash)=>text.slice(0, token.hashStart) + hash + text.slice(token.hashStart + HASH_LENGTH);
|
|
38
|
+
const computeHash = (text)=>{
|
|
39
|
+
const normalized = text.replaceAll('\r\n', '\n');
|
|
40
|
+
const token = findToken(normalized);
|
|
41
|
+
const withPlaceholder = replaceHash(normalized, token, PLACEHOLDER);
|
|
42
|
+
const hashable = replaceBodies(withPlaceholder, scanSections(withPlaceholder), ()=>'');
|
|
43
|
+
return createHash('sha256').update(hashable).digest('hex').slice(0, HASH_LENGTH);
|
|
44
|
+
};
|
|
45
|
+
export const signatureToken = ()=>`${TOKEN_NAME} v${VERSION}:${PLACEHOLDER}`;
|
|
46
|
+
const insertHeader = (text, options)=>{
|
|
47
|
+
const style = resolveCommentStyle(options);
|
|
48
|
+
const lineBreak = text.includes('\r\n') ? '\r\n' : '\n';
|
|
49
|
+
const header = renderComment(style, `${signatureToken()} ${options.message ?? DEFAULT_MESSAGE}`) + lineBreak;
|
|
50
|
+
if (text.startsWith('#!') || text.startsWith('<?xml')) {
|
|
51
|
+
const firstLineEnd = text.indexOf('\n');
|
|
52
|
+
return firstLineEnd === -1 ? text + lineBreak + header : text.slice(0, firstLineEnd + 1) + header + text.slice(firstLineEnd + 1);
|
|
53
|
+
}
|
|
54
|
+
return header + text;
|
|
55
|
+
};
|
|
56
|
+
export const sign = (text, options)=>{
|
|
57
|
+
let withToken = text;
|
|
58
|
+
if (findToken(text) === null) {
|
|
59
|
+
if (!options) {
|
|
60
|
+
throw new TypeError('Text has no signature token, provide `comment` or `filePath`');
|
|
61
|
+
}
|
|
62
|
+
withToken = insertHeader(text, options);
|
|
63
|
+
}
|
|
64
|
+
return replaceHash(withToken, findToken(withToken), computeHash(withToken));
|
|
65
|
+
};
|
|
66
|
+
export const verify = (text)=>{
|
|
67
|
+
try {
|
|
68
|
+
const token = findToken(text.replaceAll('\r\n', '\n'));
|
|
69
|
+
if (token === null) {
|
|
70
|
+
return {
|
|
71
|
+
status: 'unsigned'
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
status: computeHash(text) === token.hash ? 'valid' : 'invalid'
|
|
76
|
+
};
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return {
|
|
79
|
+
status: 'malformed',
|
|
80
|
+
error: err
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
//# sourceMappingURL=signature.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "generated-signature",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Sign generated files so hand edits are detected, while keeping manual sections editable",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"author": "JacobLey",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/JacobLey/leyman.git",
|
|
14
|
+
"directory": "tools/generated-signature/generated-signature"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/JacobLey/leyman/tree/main/tools/generated-signature/generated-signature#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/JacobLey/leyman/issues"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"codegen",
|
|
22
|
+
"generated",
|
|
23
|
+
"signature",
|
|
24
|
+
"checksum",
|
|
25
|
+
"eslint",
|
|
26
|
+
"eslintplugin"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./dist/generated-signature.js",
|
|
31
|
+
"./eslint": "./dist/eslint/plugin.js"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"!dist/**/*.map",
|
|
36
|
+
"!dist/tsconfig.tsbuildinfo"
|
|
37
|
+
],
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"eslint": "^10.12.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"eslint": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@biomejs/biome": "^2.5.14",
|
|
48
|
+
"@leyman/eslint-config": "^0.0.1",
|
|
49
|
+
"@leyman/expect": "^0.0.1",
|
|
50
|
+
"@swc/cli": "^0.8.1",
|
|
51
|
+
"@swc/core": "^1.16.2",
|
|
52
|
+
"@types/mocha": "^10.0.10",
|
|
53
|
+
"@types/node": "^24.19.0",
|
|
54
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
55
|
+
"c8": "^12.0.0",
|
|
56
|
+
"eslint": "^10.12.0",
|
|
57
|
+
"expect-type": "^1.4.0",
|
|
58
|
+
"mocha": "^12.0.2",
|
|
59
|
+
"mocha-chain": "^1.0.1",
|
|
60
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2"
|
|
61
|
+
},
|
|
62
|
+
"imports": {
|
|
63
|
+
"#internal/*": "./dist/*"
|
|
64
|
+
}
|
|
6
65
|
}
|