@rm-industries/oxlint-plugin-headstamp 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RM Industries
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,283 @@
1
- # Temporary Holding Version
1
+ # Headstamp
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
+ An oxlint-first plugin that validates templated source headers and inserts missing
4
+ ones safely. Supports JavaScript, TypeScript, JSX, and TSX.
5
+
6
+ Supports complete literal templates and named, single-line placeholders with regex
7
+ validation, safe repair of invalid fields, and incomplete scaffolds requiring human
8
+ input.
9
+
10
+ ## Requirements
11
+
12
+ - Node.js 22.18+ on the 22.x line, or Node.js 24+.
13
+ - The Node-based oxlint package, version `^1.88.0` (1.88.0 or later within 1.x).
14
+
15
+ Headstamp uses oxlint's `createOnce` API with a `Program` visitor. JavaScript plugins
16
+ are still an alpha oxlint feature. CI verifies the lockfile's exact versions,
17
+ currently oxlint 1.88.0. No benchmarked performance improvement is claimed.
18
+
19
+ Development and peer dependencies use caret ranges. The committed lockfile and
20
+ `npm ci` keep installs reproducible; dependency updates must pass the same checks.
21
+
22
+ ## Try the local package
23
+
24
+ The npm package has not been published. Build this checkout first:
25
+
26
+ ```sh
27
+ npm ci
28
+ npm run build
29
+ ```
30
+
31
+ Then install it in your consuming project:
32
+
33
+ ```sh
34
+ npm install --save-dev /path/to/headstamp oxlint@1.88.0
35
+ ```
36
+
37
+ ## Configure
38
+
39
+ In your existing `oxlint.config.ts`:
40
+
41
+ ```ts
42
+ import type { HeaderOptions } from '@rm-industries/oxlint-plugin-headstamp';
43
+ import { defineConfig } from 'oxlint';
44
+
45
+ const header = {
46
+ template: `/*
47
+ * Copyright {{years}} {{owner}}
48
+ */`,
49
+ values: {
50
+ years: {
51
+ pattern: '\\d{4}(?:-\\d{4})?',
52
+ replacement: String(new Date().getUTCFullYear()),
53
+ },
54
+ owner: { pattern: 'Acme', replacement: 'Acme' },
55
+ },
56
+ } satisfies HeaderOptions;
57
+
58
+ export default defineConfig({
59
+ jsPlugins: [{ name: 'headstamp', specifier: '@rm-industries/oxlint-plugin-headstamp' }],
60
+ overrides: [
61
+ {
62
+ files: ['src/**/*.{js,ts,jsx,tsx}'],
63
+ rules: { 'headstamp/header': ['error', header] },
64
+ },
65
+ ],
66
+ });
67
+ ```
68
+
69
+ Use ordinary oxlint overrides and ignores to choose files. To store the template in
70
+ `HEADER.txt`, read it in configuration code:
71
+
72
+ ```ts
73
+ import { readFileSync } from 'node:fs';
74
+
75
+ const header = {
76
+ template: readFileSync(new URL('./HEADER.txt', import.meta.url), 'utf8'),
77
+ } satisfies HeaderOptions;
78
+ ```
79
+
80
+ Text files contain the same comment template, including placeholders; their
81
+ extension has no meaning to Headstamp. Keep the `values` map alongside the template
82
+ property when reading from a file. Configuration can compute replacement values or
83
+ import shared options, but the rule receives strings, not executable functions.
84
+ JSON oxlint configuration also works with inline template and value strings.
85
+
86
+ ## Placeholders
87
+
88
+ Use `{{name}}` and define each name once in `values`, with a regex `pattern` string
89
+ and a literal `replacement` string. Names use `[A-Za-z_][A-Za-z0-9_]*`. Repeated,
90
+ undefined, or malformed placeholders and unused definitions are configuration errors.
91
+
92
+ Patterns must match the entire field value. Existing `2020` and `2020-2024` years
93
+ remain valid with the example above, even when the generated replacement is `2026`.
94
+ Regex capture groups and backreferences are local to each pattern. Replacement
95
+ strings are inserted literally, including dollar signs and backslashes.
96
+
97
+ Placeholders capture single-line values and require literal text between them. A
98
+ separator may occur inside a value if the patterns identify exactly one valid split.
99
+ Multiple valid splits produce a diagnostic without autofix. A configuration whose
100
+ otherwise-valid generated header has ambiguous field boundaries is rejected.
101
+
102
+ Invalid existing fields are reported by name when their boundaries are clear.
103
+ `--fix` replaces a field only when its configured replacement matches the entire
104
+ pattern, preserving every valid field, including historical years and ranges. If a
105
+ replacement fails its pattern, existing text in that field remains unchanged; other
106
+ safe fields may still be repaired. A repair is refused if it would make boundaries
107
+ ambiguous, reinterpret another field, or break the comment. Literal text identifies
108
+ the header structure; changed wording or ambiguous extraction requires manual review.
109
+
110
+ Replacements may intentionally fail their pattern; this is not a configuration error.
111
+ They must still be single-line and preserve the complete comment. Missing-header
112
+ generation uses replacement strings, while validation uses field patterns.
113
+
114
+ ## Incomplete scaffolds
115
+
116
+ A field can require human input by providing an intentionally invalid replacement:
117
+
118
+ ```ts
119
+ const header = {
120
+ template: '// Owner: {{owner}}\n// Description: {{description}}',
121
+ values: {
122
+ owner: { pattern: 'Acme', replacement: 'Acme' },
123
+ description: { pattern: '\\S.{9,}', replacement: '' },
124
+ },
125
+ } satisfies HeaderOptions;
126
+ ```
127
+
128
+ The description must start with a non-whitespace character and contain at least ten
129
+ characters. Running `oxlint --fix` on a file without a header inserts:
130
+
131
+ ```ts
132
+ // Owner: Acme
133
+ // Description:
134
+
135
+ export {};
136
+ ```
137
+
138
+ The generated description line retains the template’s space after the colon.
139
+ Successful insertion still leaves a lint error for `description`, so the CLI exits
140
+ unsuccessfully. Fill in a valid description, then lint again to pass. An invalid
141
+ nonempty replacement such as `TODO` works the same way.
142
+
143
+ Repeated fixes preserve the scaffold and keep reporting the incomplete field
144
+ without offering a no-op edit. Existing unfinished text such as `WIP` is preserved
145
+ rather than reset to the replacement. Other independently safe repairs, such as
146
+ correcting the owner, still apply while the description remains incomplete. Valid
147
+ fields, including historical years, stay unchanged. Ambiguous field boundaries
148
+ still require manual review.
149
+
150
+ ## Permitted leading comments
151
+
152
+ Use `allowedLeadingComments` to preserve explicitly permitted directives or pragmas
153
+ before the required header:
154
+
155
+ ```ts
156
+ const header = {
157
+ template: '/* Acme */',
158
+ allowedLeadingComments: ['// @ts-check', { pattern: '/\\*\\* @jest-environment [\\s\\S]*?\\*/' }],
159
+ } satisfies HeaderOptions;
160
+ ```
161
+
162
+ The default is an empty allowlist. Entries are complete literal comments or objects
163
+ with a regex `pattern` string. Patterns match the entire comment, including its
164
+ delimiters, with no flags; use explicit multiline expressions when needed. Literal
165
+ and source CRLF are normalized to LF for matching, while original prefix bytes are
166
+ preserved. Malformed literals, regexes, or option shapes are configuration errors.
167
+
168
+ A matching unit is one block comment or a consecutive `//` group. Headstamp never
169
+ splits a group to accept only some of its lines. Separate an existing line-comment
170
+ prefix and line-comment header with a blank line. Insertion adds the newline(s)
171
+ needed to keep these groups separate on subsequent runs.
172
+
173
+ Only the opening prefix after any BOM/shebang is considered. The required header
174
+ takes precedence over a matching allowlist entry, including when its fields need
175
+ repair. An ambiguous header remains diagnostic-only. Unknown comments stop the
176
+ search without autofix; source code ends the prefix, and later comments are not
177
+ searched for a header.
178
+
179
+ Permitted comments are never generated, repaired, or reordered. Their text and
180
+ spacing are preserved, including whitespace before an existing or inserted header.
181
+ Insertion adds a separator only when needed. Prefix-only files receive a header
182
+ with one final newline. Source indentation, historical fields, and unfinished
183
+ human input remain protected, and repeated fixes converge.
184
+
185
+ The allowlist does not change spacing before the header; `blankLinesAfter` controls
186
+ only the boundary after it.
187
+ This rule preserves source LF/CRLF; it does not read EditorConfig or convert the
188
+ rest of a file's line endings.
189
+
190
+ ## Spacing after the header
191
+
192
+ Set `blankLinesAfter` to the exact number of empty lines required before subsequent
193
+ content. It defaults to `1`; values must be nonnegative safe integers.
194
+
195
+ ```ts
196
+ const header = {
197
+ template: '/* Acme */',
198
+ blankLinesAfter: 0,
199
+ } satisfies HeaderOptions;
200
+ ```
201
+
202
+ Zero places source on the following line without an empty line. A value of `3`
203
+ requires three empty lines. Fixes add missing or remove excess lines, preserving
204
+ source indentation and LF/CRLF. Empty and header-only files always end with one
205
+ final newline, regardless of the configured count. Prefix spacing stays unchanged.
206
+
207
+ A zero count cannot safely join a `//` header to following `//` documentation:
208
+ that would merge separate comment groups on the next lint run. Headstamp reports
209
+ this conflict without changing that separator. Use a block header or at least one
210
+ blank line. Independently safe field repairs still apply.
211
+
212
+ Spacing fixes can also apply while required scaffold fields are incomplete. The
213
+ unfinished text and its lint errors remain until valid human input is supplied.
214
+ Repeated fixes converge and do not offer no-op edits.
215
+
216
+ ## Header behavior
217
+
218
+ - Templates contain exactly one complete `/* ... */` comment or consecutive `//`
219
+ lines. Include the delimiters and desired internal whitespace. Trailing template
220
+ newlines are ignored; LF and CRLF templates match either source convention.
221
+ - Literal header text must match exactly, including internal whitespace. Placeholder
222
+ values must match their patterns. Recognized invalid fields may be repaired as
223
+ described above. Changed literal text or an unrecognized opening comment produces
224
+ a diagnostic without autofix.
225
+ Consecutive opening `//` lines form one comment group: an extra line in that group
226
+ requires manual review.
227
+ - `oxlint --fix` inserts a missing header after any shebang and permitted opening
228
+ comments, preserving a BOM, source code, and separate later comments.
229
+ - Headers get exactly `blankLinesAfter` empty lines before subsequent content
230
+ (default `1`) when the edit preserves separate comment groups. Indentation of
231
+ subsequent content is retained. Without a permitted prefix, leading whitespace
232
+ before the header is removed; prefix spacing is preserved when present.
233
+ - Empty, whitespace-only, and header-only files end with the header and exactly one
234
+ final newline. They do not receive an empty trailing paragraph.
235
+ - Edits use the first LF or CRLF convention present in the source, defaulting to LF
236
+ when none exists. They do not rewrite the rest of the file.
237
+ - Repeated fixes converge and never offer an edit that makes no change.
238
+ - Invalid options or malformed templates fail rather than inserting source code
239
+ from an invalid template.
240
+
241
+ Oxlint 1.88.0 rejects a BOM immediately followed by a shebang during parsing, before
242
+ plugins run. BOM-only and shebang-only inputs are supported and tested separately;
243
+ Headstamp cannot fix a file that oxlint cannot parse.
244
+
245
+ ## Development
246
+
247
+ ```sh
248
+ npm run typecheck
249
+ npm run test:rule
250
+ npm test
251
+ npm run lint
252
+ npm run format:check
253
+ ```
254
+
255
+ Run `npm run format` to apply Oxfmt formatting across the repository. EditorConfig
256
+ sets UTF-8, LF, final newlines, two-space indentation, and a 120-column target.
257
+ The typed Oxfmt config adds single quotes, trailing commas, and import/package
258
+ sorting. Generated files and local state are excluded through the Toptal-generated
259
+ Git ignore rules and the project's additional exclusions.
260
+
261
+ GitHub Actions checks formatting and lint on pull requests and pushes to the main
262
+ branch. A Node 22.18, 24, and 26 matrix runs strict typechecking, the package build,
263
+ all tests, and package-content inspection. CI checks formatting without rewriting
264
+ files. Publishing a GitHub release triggers a separate npm release workflow after
265
+ those same checks; manual release-workflow runs prepare an artifact without
266
+ publishing. See [RELEASING.md](RELEASING.md) for bootstrap, trusted-publisher setup,
267
+ and version/tag requirements. No npm package has been published as part of adding
268
+ this workflow.
269
+
270
+ Tests use oxlint's public rule-testing API and real CLI runs. CLI tests exercise
271
+ TypeScript configuration and exported types, external templates, supported source
272
+ languages, overrides/ignores, distinct configurations, and repeated fixes.
273
+ Placeholder tests cover historical values, whole-value matching, literal replacement
274
+ characters, malformed configuration, ambiguity, and source preservation.
275
+
276
+ Implementation and tests are written in TypeScript. Both test commands strictly
277
+ typecheck source and tests, then run the TypeScript tests with Node's built-in type
278
+ stripping. The full suite builds the package first for CLI integration. Only plugin
279
+ source is emitted to the package; tests are never included in the build output.
280
+
281
+ Package output is ESM JavaScript plus TypeScript declarations. Build artifacts and
282
+ development-only files are excluded from the published package surface; inspect it
283
+ without publishing with `npm pack --dry-run`.
@@ -0,0 +1,12 @@
1
+ import type { RuleTester } from 'oxlint/plugins-dev';
2
+ export type { HeaderOptions, HeaderValue } from './template.ts';
3
+ type Rule = Parameters<RuleTester['run']>[1];
4
+ declare const plugin: {
5
+ meta: {
6
+ name: string;
7
+ };
8
+ rules: {
9
+ header: Rule;
10
+ };
11
+ };
12
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,166 @@
1
+ import { prepareHeader } from './template.js';
2
+ const header = {
3
+ meta: {
4
+ type: 'layout',
5
+ fixable: 'code',
6
+ schema: [
7
+ {
8
+ type: 'object',
9
+ properties: {
10
+ template: { type: 'string' },
11
+ blankLinesAfter: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
12
+ allowedLeadingComments: {
13
+ type: 'array',
14
+ items: {
15
+ anyOf: [
16
+ { type: 'string' },
17
+ {
18
+ type: 'object',
19
+ properties: { pattern: { type: 'string' } },
20
+ required: ['pattern'],
21
+ additionalProperties: false,
22
+ },
23
+ ],
24
+ },
25
+ },
26
+ values: {
27
+ type: 'object',
28
+ additionalProperties: {
29
+ type: 'object',
30
+ properties: { pattern: { type: 'string' }, replacement: { type: 'string' } },
31
+ required: ['pattern', 'replacement'],
32
+ additionalProperties: false,
33
+ },
34
+ },
35
+ },
36
+ required: ['template'],
37
+ additionalProperties: false,
38
+ },
39
+ ],
40
+ messages: {
41
+ missing: 'Missing required header.',
42
+ mismatch: 'Opening comment does not match the required header; review it manually.',
43
+ spacing: 'Normalize spacing around the required header.',
44
+ unsafeSpacing: 'Zero blank lines would merge the header with following line comments; use a block header or at least one blank line.',
45
+ invalidValue: 'Header field(s) {{fields}} do not match their patterns.',
46
+ },
47
+ },
48
+ createOnce(context) {
49
+ const templates = new Map();
50
+ return {
51
+ Program(node) {
52
+ const hasOneOptionsArgument = context.options.length === 1;
53
+ if (!hasOneOptionsArgument)
54
+ throw new Error('Headstamp requires exactly one options object.');
55
+ const optionsKey = JSON.stringify(context.options);
56
+ let prepared = templates.get(optionsKey);
57
+ if (prepared === undefined) {
58
+ prepared = prepareHeader(context.options[0]);
59
+ templates.set(optionsKey, prepared);
60
+ }
61
+ let headerText = prepared.rendered;
62
+ let invalidFields = [];
63
+ let hasFieldRepairs = false;
64
+ const source = context.sourceCode;
65
+ const text = source.text;
66
+ const eol = /\r\n|\n/.exec(text)?.[0] ?? '\n';
67
+ const shebang = /^#![^\r\n]*(?:\r\n|\n|$)/.exec(text)?.[0] ?? '';
68
+ let anchor = shebang.length;
69
+ let start = anchor + /^\s*/.exec(text.slice(anchor))[0].length;
70
+ const comments = source.getAllComments();
71
+ let commentIndex = comments.findIndex((comment) => comment.range[0] === start);
72
+ let first = commentIndex < 0 ? undefined : comments[commentIndex];
73
+ let end = anchor;
74
+ let hasLeadingComments = false;
75
+ let prefixWhitespace = '';
76
+ let lastLeadingCommentIsLine = false;
77
+ while (first) {
78
+ end = first.range[1];
79
+ if (first.type === 'Line') {
80
+ for (let nextIndex = commentIndex + 1; nextIndex < comments.length; nextIndex++) {
81
+ const comment = comments[nextIndex];
82
+ const isLineComment = comment.type === 'Line';
83
+ const followsOnNextLine = /^(?:\r\n|\n)[\t ]*$/.test(text.slice(end, comment.range[0]));
84
+ if (!isLineComment || !followsOnNextLine)
85
+ break;
86
+ end = comment.range[1];
87
+ commentIndex = nextIndex;
88
+ }
89
+ }
90
+ headerText = text.slice(start, end).replace(/\r\n/g, '\n');
91
+ const inspection = prepared.inspect(headerText);
92
+ if (inspection === null && prepared.isAllowedLeadingComment(headerText)) {
93
+ hasLeadingComments = true;
94
+ lastLeadingCommentIsLine = first.type === 'Line';
95
+ prefixWhitespace = /^\s*/.exec(text.slice(end))[0];
96
+ anchor = end + prefixWhitespace.length;
97
+ start = anchor;
98
+ end = anchor;
99
+ first = comments[++commentIndex];
100
+ if (first?.range[0] !== start)
101
+ first = undefined;
102
+ headerText = prepared.rendered;
103
+ continue;
104
+ }
105
+ if (inspection === null || inspection === 'ambiguous') {
106
+ context.report({ node, messageId: 'mismatch' });
107
+ return;
108
+ }
109
+ invalidFields = inspection.invalidFields;
110
+ hasFieldRepairs = inspection.repaired !== headerText;
111
+ headerText = inspection.repaired;
112
+ break;
113
+ }
114
+ const whitespace = /^\s*/.exec(text.slice(end))[0];
115
+ const contentStart = end + whitespace.length;
116
+ const hasContent = contentStart < text.length;
117
+ const sourceWhitespace = hasLeadingComments && !first ? prefixWhitespace : whitespace;
118
+ const indent = hasContent ? sourceWhitespace.slice(sourceWhitespace.lastIndexOf('\n') + 1) : '';
119
+ const needsShebangNewline = shebang.length > 0 && !shebang.endsWith('\n');
120
+ let prefix = needsShebangNewline ? eol : '';
121
+ if (hasLeadingComments && !first) {
122
+ // A blank line prevents two line-comment groups from merging on the next lint run.
123
+ const needsLineGroupBoundary = lastLeadingCommentIsLine && headerText.startsWith('//');
124
+ const requiredNewlines = needsLineGroupBoundary ? 2 : 1;
125
+ const existingNewlines = prefixWhitespace.match(/\n/g)?.length ?? 0;
126
+ prefix = eol.repeat(Math.max(0, requiredNewlines - existingNewlines));
127
+ }
128
+ const wouldMergeLineGroups = first?.type === 'Line' && prepared.blankLinesAfter === 0 && text.startsWith('//', contentStart);
129
+ if (wouldMergeLineGroups)
130
+ context.report({ node, messageId: 'unsafeSpacing' });
131
+ const separator = wouldMergeLineGroups
132
+ ? whitespace
133
+ : eol + (hasContent ? eol.repeat(prepared.blankLinesAfter) + indent : '');
134
+ const replacement = prefix + headerText.replace(/\n/g, eol) + separator;
135
+ let remainingInvalidFields = prepared.invalidReplacementFields;
136
+ if (first) {
137
+ remainingInvalidFields = hasFieldRepairs
138
+ ? invalidFields.filter((name) => prepared.invalidReplacementFields.includes(name))
139
+ : invalidFields;
140
+ }
141
+ const repairedFields = invalidFields.filter((name) => !remainingInvalidFields.includes(name));
142
+ const hasChanges = text.slice(anchor, contentStart) !== replacement;
143
+ if (hasChanges) {
144
+ let messageId = first ? 'spacing' : 'missing';
145
+ if (repairedFields.length > 0)
146
+ messageId = 'invalidValue';
147
+ context.report({
148
+ node,
149
+ messageId,
150
+ data: { fields: repairedFields.join(', ') },
151
+ fix: (fixer) => fixer.replaceTextRange([anchor, contentStart], replacement),
152
+ });
153
+ }
154
+ // Oxlint removes fixed diagnostics without re-linting; retain errors needing human input.
155
+ if (remainingInvalidFields.length > 0) {
156
+ context.report({ node, messageId: 'invalidValue', data: { fields: remainingInvalidFields.join(', ') } });
157
+ }
158
+ },
159
+ };
160
+ },
161
+ };
162
+ const plugin = {
163
+ meta: { name: '@rm-industries/oxlint-plugin-headstamp' },
164
+ rules: { header },
165
+ };
166
+ export default plugin;
@@ -0,0 +1,22 @@
1
+ export interface HeaderValue {
2
+ pattern: string;
3
+ replacement: string;
4
+ }
5
+ export interface HeaderOptions {
6
+ template: string;
7
+ values?: Record<string, HeaderValue>;
8
+ allowedLeadingComments?: Array<string | {
9
+ pattern: string;
10
+ }>;
11
+ blankLinesAfter?: number;
12
+ }
13
+ export declare function prepareHeader(options: unknown): {
14
+ rendered: string;
15
+ blankLinesAfter: number;
16
+ inspect: (comment: string) => {
17
+ invalidFields: string[];
18
+ repaired: string;
19
+ } | 'ambiguous' | null;
20
+ invalidReplacementFields: string[];
21
+ isAllowedLeadingComment: (comment: string) => boolean;
22
+ };
@@ -0,0 +1,191 @@
1
+ function isRecord(value) {
2
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+ function isCompleteComment(text) {
5
+ const isBlockComment = text.length >= 4 && text.startsWith('/*') && text.indexOf('*/') === text.length - 2;
6
+ const isLineCommentGroup = text.split('\n').every((line) => line.startsWith('//'));
7
+ const hasSupportedCommentStyle = isBlockComment || isLineCommentGroup;
8
+ const hasUnsupportedLineEndings = /[\r\u2028\u2029]/.test(text);
9
+ return hasSupportedCommentStyle && !hasUnsupportedLineEndings;
10
+ }
11
+ function fullValuePattern(pattern) {
12
+ const expression = new RegExp(pattern);
13
+ return new RegExp(`^(?:${expression.source})$`);
14
+ }
15
+ function matchValues(comment, parts, fields, checkPatterns) {
16
+ if (!comment.startsWith(parts[0]))
17
+ return null;
18
+ const memo = new Map();
19
+ function visit(index, start) {
20
+ if (index === fields.length)
21
+ return { count: start === comment.length ? 1 : 0, values: [] };
22
+ const key = `${index}:${start}`;
23
+ const cached = memo.get(key);
24
+ if (cached)
25
+ return cached;
26
+ const field = fields[index];
27
+ const delimiter = parts[index + 1];
28
+ const nextLineBreak = comment.slice(start).search(/[\r\n\u2028\u2029]/);
29
+ const lineEnd = nextLineBreak < 0 ? comment.length : start + nextLineBreak;
30
+ let end = delimiter.length === 0 ? comment.length : comment.indexOf(delimiter, start);
31
+ const result = { count: 0, values: [] };
32
+ while (end >= 0 && end <= lineEnd) {
33
+ const value = comment.slice(start, end);
34
+ const isAllowedValue = !checkPatterns || field.pattern.test(value);
35
+ if (isAllowedValue) {
36
+ const tail = visit(index + 1, end + delimiter.length);
37
+ if (tail.count > 0) {
38
+ if (result.count === 0)
39
+ result.values = [value, ...tail.values];
40
+ result.count += tail.count;
41
+ // Two matches already prove ambiguity; do not enumerate further splits.
42
+ if (result.count >= 2)
43
+ break;
44
+ }
45
+ }
46
+ end = delimiter.length === 0 ? -1 : comment.indexOf(delimiter, end + 1);
47
+ }
48
+ memo.set(key, result);
49
+ return result;
50
+ }
51
+ const result = visit(0, parts[0].length);
52
+ if (result.count === 0)
53
+ return null;
54
+ return result.count === 1 ? result.values : 'ambiguous';
55
+ }
56
+ export function prepareHeader(options) {
57
+ if (!isRecord(options))
58
+ throw new Error('Headstamp requires a template string and optional values map.');
59
+ const configuredTemplate = options.template;
60
+ const hasTemplateString = typeof configuredTemplate === 'string';
61
+ const hasKnownOptions = Object.keys(options).every((key) => key === 'template' || key === 'values' || key === 'allowedLeadingComments' || key === 'blankLinesAfter');
62
+ if (!hasTemplateString || !hasKnownOptions)
63
+ throw new Error('Headstamp requires a template string and optional values map.');
64
+ const blankLinesAfter = options.blankLinesAfter === undefined ? 1 : options.blankLinesAfter;
65
+ const hasValidBlankLineCount = typeof blankLinesAfter === 'number' && Number.isSafeInteger(blankLinesAfter) && blankLinesAfter >= 0;
66
+ if (!hasValidBlankLineCount)
67
+ throw new Error('Headstamp blankLinesAfter must be a nonnegative safe integer.');
68
+ const leadingComments = options.allowedLeadingComments === undefined ? [] : options.allowedLeadingComments;
69
+ if (!Array.isArray(leadingComments))
70
+ throw new Error('Headstamp allowedLeadingComments must be an array.');
71
+ const allowedLeadingComments = leadingComments.map((comment) => {
72
+ if (typeof comment === 'string') {
73
+ const normalized = comment.replace(/\r\n/g, '\n').replace(/\n+$/, '');
74
+ if (!isCompleteComment(normalized))
75
+ throw new Error('Headstamp permitted literals must be complete comments.');
76
+ return normalized;
77
+ }
78
+ if (!isRecord(comment))
79
+ throw new Error('Headstamp permitted comments require a literal comment or pattern string.');
80
+ const pattern = comment.pattern;
81
+ const hasPatternString = typeof pattern === 'string';
82
+ const hasOnlyPattern = Object.keys(comment).length === 1;
83
+ if (!hasPatternString || !hasOnlyPattern)
84
+ throw new Error('Headstamp permitted comments require a literal comment or pattern string.');
85
+ try {
86
+ return fullValuePattern(pattern);
87
+ }
88
+ catch {
89
+ throw new Error('Headstamp permitted comment has an invalid regex.');
90
+ }
91
+ });
92
+ const values = options.values === undefined ? {} : options.values;
93
+ if (!isRecord(values))
94
+ throw new Error('Headstamp values must be an object.');
95
+ const template = configuredTemplate.replace(/\r\n/g, '\n').replace(/\n+$/, '');
96
+ if (!isCompleteComment(template))
97
+ throw new Error('Headstamp template must be one complete block comment or consecutive // comments using LF or CRLF.');
98
+ const parts = [];
99
+ const fields = [];
100
+ const names = new Set();
101
+ let cursor = 0;
102
+ for (const match of template.matchAll(/\{\{([A-Za-z_]\w*)\}\}/g)) {
103
+ const name = match[1];
104
+ const literal = template.slice(cursor, match.index);
105
+ const isAdjacentPlaceholder = fields.length > 0 && literal.length === 0;
106
+ if (isAdjacentPlaceholder)
107
+ throw new Error('Headstamp placeholders must be separated by literal text.');
108
+ if (names.has(name))
109
+ throw new Error(`Headstamp placeholder "${name}" must occur once.`);
110
+ if (!Object.hasOwn(values, name))
111
+ throw new Error(`Headstamp placeholder "${name}" has no definition.`);
112
+ const definition = values[name];
113
+ if (!isRecord(definition))
114
+ throw new Error(`Headstamp placeholder "${name}" requires pattern and replacement strings.`);
115
+ const pattern = definition.pattern;
116
+ const replacement = definition.replacement;
117
+ const hasPatternString = typeof pattern === 'string';
118
+ const hasReplacementString = typeof replacement === 'string';
119
+ const hasOnlyValueProperties = Object.keys(definition).length === 2;
120
+ if (!hasPatternString || !hasReplacementString || !hasOnlyValueProperties)
121
+ throw new Error(`Headstamp placeholder "${name}" requires pattern and replacement strings.`);
122
+ const hasMultilineReplacement = /[\r\n\u2028\u2029]/.test(replacement);
123
+ if (hasMultilineReplacement)
124
+ throw new Error(`Headstamp placeholder "${name}" replacement must be single-line.`);
125
+ let validator;
126
+ try {
127
+ validator = fullValuePattern(pattern);
128
+ }
129
+ catch {
130
+ throw new Error(`Headstamp placeholder "${name}" has an invalid regex.`);
131
+ }
132
+ parts.push(literal);
133
+ fields.push({ name, pattern: validator, replacement });
134
+ names.add(name);
135
+ cursor = match.index + match[0].length;
136
+ }
137
+ parts.push(template.slice(cursor));
138
+ const hasMalformedPlaceholder = parts.some((part) => part.includes('{{') || part.includes('}}'));
139
+ if (hasMalformedPlaceholder)
140
+ throw new Error('Headstamp placeholders must use {{name}} with an identifier name.');
141
+ if (Object.keys(values).length !== fields.length)
142
+ throw new Error('Headstamp values must define exactly the template placeholders.');
143
+ function render(fieldValues) {
144
+ let comment = parts[0];
145
+ for (const [index, value] of fieldValues.entries())
146
+ comment += value + parts[index + 1];
147
+ return comment;
148
+ }
149
+ const rendered = render(fields.map((field) => field.replacement));
150
+ if (!isCompleteComment(rendered))
151
+ throw new Error('Headstamp replacement values must preserve the complete comment.');
152
+ function inspect(comment) {
153
+ if (fields.length === 0)
154
+ return comment === template ? { invalidFields: [], repaired: comment } : null;
155
+ const values = matchValues(comment, parts, fields, true) ?? matchValues(comment, parts, fields, false);
156
+ if (values === null || values === 'ambiguous')
157
+ return values;
158
+ const invalidFields = [];
159
+ const repairedValues = values.map((value, index) => {
160
+ const field = fields[index];
161
+ if (field.pattern.test(value))
162
+ return value;
163
+ invalidFields.push(field.name);
164
+ const hasValidReplacement = field.pattern.test(field.replacement);
165
+ return hasValidReplacement ? field.replacement : value;
166
+ });
167
+ const repaired = render(repairedValues);
168
+ if (repaired === comment)
169
+ return { invalidFields, repaired };
170
+ const resultingValues = matchValues(repaired, parts, fields, true) ?? matchValues(repaired, parts, fields, false);
171
+ const preservesFieldBoundaries = resultingValues !== null &&
172
+ resultingValues !== 'ambiguous' &&
173
+ resultingValues.every((value, index) => value === repairedValues[index]);
174
+ const hasSafeRepair = isCompleteComment(repaired) && preservesFieldBoundaries;
175
+ return { invalidFields, repaired: hasSafeRepair ? repaired : comment };
176
+ }
177
+ const invalidReplacementFields = fields
178
+ .filter((field) => !field.pattern.test(field.replacement))
179
+ .map((field) => field.name);
180
+ const replacementsMatchPatterns = invalidReplacementFields.length === 0;
181
+ const hasAmbiguousGeneratedHeader = replacementsMatchPatterns && inspect(rendered) === 'ambiguous';
182
+ if (hasAmbiguousGeneratedHeader)
183
+ throw new Error('Headstamp generated header has ambiguous placeholder boundaries.');
184
+ return {
185
+ rendered,
186
+ blankLinesAfter,
187
+ inspect,
188
+ invalidReplacementFields,
189
+ isAllowedLeadingComment: (comment) => allowedLeadingComments.some((allowed) => typeof allowed === 'string' ? allowed === comment : allowed.test(comment)),
190
+ };
191
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@rm-industries/oxlint-plugin-headstamp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Oxlint-first header templates, regex validation, and safe insertion.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/rm-industries/headstamp.git"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.org"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "typecheck": "tsc --project tsconfig.test.json",
28
+ "test": "npm run typecheck && npm run build && node --test test/*.test.ts",
29
+ "test:rule": "npm run typecheck && node --test test/header.test.ts",
30
+ "lint": "oxlint src test scripts oxfmt.config.ts",
31
+ "prepack": "npm run build",
32
+ "format:check": "oxfmt --check .",
33
+ "format": "oxfmt --write ."
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.20.5",
37
+ "oxfmt": "^0.73.0",
38
+ "oxlint": "^1.88.0",
39
+ "typescript": "^7.0.2"
40
+ },
41
+ "peerDependencies": {
42
+ "oxlint": "^1.88.0"
43
+ },
44
+ "engines": {
45
+ "node": "^22.18.0 || >=24.0.0"
46
+ }
47
+ }