generated-signature 0.0.0-stage → 0.1.0

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 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
- # Temporary Holding Version
1
+ <div style="text-align:center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # generated-signature
4
+ Sign generated files so hand edits are detected, while keeping manual sections editable.
5
+
6
+ [![npm package](https://badge.fury.io/js/generated-signature.svg)](https://www.npmjs.com/package/generated-signature)
7
+ [![License](https://img.shields.io/npm/l/generated-signature.svg)](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,6 @@
1
+ import type { Rule } from 'eslint';
2
+ /**
3
+ * Report generated files that were edited outside of their manual sections.
4
+ */
5
+ export declare const valid: Rule.RuleModule;
6
+ //# sourceMappingURL=valid.d.ts.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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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
  }