@rm-industries/oxlint-plugin-headstamp 0.0.0-stage → 0.1.2

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,345 @@
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.93.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
+ ## Tested source formats
23
+
24
+ Real CLI tests verify `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts`, `.jsx`, and
25
+ `.tsx`, using module, CommonJS, typed, and JSX syntax as appropriate. For every
26
+ extension, tests require missing-header diagnostics, exact header insertion,
27
+ valid-header acceptance, unchanged source bodies, and repeated-fix convergence.
28
+ They also cover LF/CRLF, BOM-only and shebang-only prefixes, and mixed-file
29
+ configuration overrides, disabled rules, and ignores.
30
+
31
+ Use oxlint's existing file discovery, overrides, and ignores; Headstamp adds no
32
+ file selector or parser. Physical-file headers in Vue, Svelte, and Astro are
33
+ deferred on oxlint 1.88.0: scriptless files do not invoke the plugin, and scripts
34
+ expose separate source/edit regions. Enabling the rule there can insert per-script
35
+ banners rather than one physical-file header. Exclude those formats with oxlint
36
+ overrides/ignores when enforcing physical headers. See the [framework investigation](docs/framework-upstream-research.md)
37
+ for separate decisions, reproducible CLI probes, and pinned upstream references.
38
+
39
+ ## Install
40
+
41
+ ```sh
42
+ npm install --save-dev @rm-industries/oxlint-plugin-headstamp oxlint@^1.88.0
43
+ ```
44
+
45
+ ## Configure
46
+
47
+ In your existing `oxlint.config.ts`:
48
+
49
+ ```ts
50
+ import type { HeaderOptions } from '@rm-industries/oxlint-plugin-headstamp';
51
+ import { defineConfig } from 'oxlint';
52
+
53
+ const header = {
54
+ template: `/*
55
+ * Copyright {{years}} {{owner}}
56
+ */`,
57
+ values: {
58
+ years: {
59
+ pattern: '\\d{4}(?:-\\d{4})?',
60
+ replacement: String(new Date().getUTCFullYear()),
61
+ },
62
+ owner: { pattern: 'Acme', replacement: 'Acme' },
63
+ },
64
+ } satisfies HeaderOptions;
65
+
66
+ export default defineConfig({
67
+ jsPlugins: [{ name: 'headstamp', specifier: '@rm-industries/oxlint-plugin-headstamp' }],
68
+ overrides: [
69
+ {
70
+ files: ['src/**/*.{js,ts,jsx,tsx}'],
71
+ rules: { 'headstamp/header': ['error', header] },
72
+ },
73
+ ],
74
+ });
75
+ ```
76
+
77
+ Use ordinary oxlint overrides and ignores to choose files. To store the template in
78
+ `HEADER.txt`, read it in configuration code:
79
+
80
+ ```ts
81
+ import { readFileSync } from 'node:fs';
82
+
83
+ const header = {
84
+ template: readFileSync(new URL('./HEADER.txt', import.meta.url), 'utf8'),
85
+ } satisfies HeaderOptions;
86
+ ```
87
+
88
+ Text files contain the same comment template, including placeholders; their
89
+ extension has no meaning to Headstamp. Keep the `values` map alongside the template
90
+ property when reading from a file. Configuration can compute replacement values or
91
+ import shared options, but the rule receives strings, not executable functions.
92
+ JSON oxlint configuration also works with inline template and value strings.
93
+
94
+ ## SPDX header example
95
+
96
+ Use this in `oxlint.config.ts`. Change `MIT` and `Acme` to your project's
97
+ license identifier and copyright holder.
98
+
99
+ ```ts
100
+ import { defineConfig } from 'oxlint';
101
+
102
+ const year = String(new Date().getUTCFullYear());
103
+
104
+ export default defineConfig({
105
+ jsPlugins: [{ name: 'headstamp', specifier: '@rm-industries/oxlint-plugin-headstamp' }],
106
+ overrides: [
107
+ {
108
+ files: ['src/**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
109
+ rules: {
110
+ 'headstamp/header': [
111
+ 'error',
112
+ {
113
+ template: '// SPDX-License-Identifier: {{license}}\n// SPDX-FileCopyrightText: {{years}} Acme',
114
+ values: {
115
+ license: { pattern: 'MIT', replacement: 'MIT' },
116
+ years: { pattern: '\\d{4}(?:-\\d{4})?', replacement: year },
117
+ },
118
+ },
119
+ ],
120
+ },
121
+ },
122
+ ],
123
+ });
124
+ ```
125
+
126
+ With `oxlint --fix`, a missing header gets the configured license and year.
127
+ An existing header with the wrong license identifier can be repaired without
128
+ changing a valid year or year range.
129
+
130
+ ### Optional Git-derived year
131
+
132
+ To use the latest commit's year as the default, replace the `year` declaration
133
+ above with this snippet. This runs Git once when the config loads. It uses the
134
+ same year for every file; it does not infer per-file history. If Git is missing
135
+ or the checkout has no commits, it falls back to the current UTC year.
136
+
137
+ ```ts
138
+ import { spawnSync } from 'node:child_process';
139
+
140
+ const commitDate = spawnSync('git', ['log', '-1', '--format=%cs'], {
141
+ cwd: new URL('.', import.meta.url),
142
+ encoding: 'utf8',
143
+ timeout: 10000,
144
+ });
145
+ const gitYear = commitDate.status === 0 ? /^(\d{4})-/.exec(commitDate.stdout)?.[1] : undefined;
146
+ const year = gitYear ?? String(new Date().getUTCFullYear());
147
+ ```
148
+
149
+ ## Placeholders
150
+
151
+ Use `{{name}}` and define each name once in `values`, with a regex `pattern` string
152
+ and a literal `replacement` string. Names use `[A-Za-z_][A-Za-z0-9_]*`. Repeated,
153
+ undefined, or malformed placeholders and unused definitions are configuration errors.
154
+
155
+ Patterns must match the entire field value. Existing `2020` and `2020-2024` years
156
+ remain valid with the example above, even when the generated replacement is `2026`.
157
+ Regex capture groups and backreferences are local to each pattern. Replacement
158
+ strings are inserted literally, including dollar signs and backslashes.
159
+
160
+ Placeholders capture single-line values and require literal text between them. A
161
+ separator may occur inside a value if the patterns identify exactly one valid split.
162
+ Multiple valid splits produce a diagnostic without autofix. A configuration whose
163
+ otherwise-valid generated header has ambiguous field boundaries is rejected.
164
+
165
+ Invalid existing fields are reported by name when their boundaries are clear.
166
+ `--fix` replaces a field only when its configured replacement matches the entire
167
+ pattern, preserving every valid field, including historical years and ranges. If a
168
+ replacement fails its pattern, existing text in that field remains unchanged; other
169
+ safe fields may still be repaired. A repair is refused if it would make boundaries
170
+ ambiguous, reinterpret another field, or break the comment. Literal text identifies
171
+ the header structure; changed wording or ambiguous extraction requires manual review.
172
+
173
+ Replacements may intentionally fail their pattern; this is not a configuration error.
174
+ They must still be single-line and preserve the complete comment. Missing-header
175
+ generation uses replacement strings, while validation uses field patterns.
176
+
177
+ ## Incomplete scaffolds
178
+
179
+ A field can require human input by providing an intentionally invalid replacement:
180
+
181
+ ```ts
182
+ const header = {
183
+ template: '// Owner: {{owner}}\n// Description: {{description}}',
184
+ values: {
185
+ owner: { pattern: 'Acme', replacement: 'Acme' },
186
+ description: { pattern: '\\S.{9,}', replacement: '' },
187
+ },
188
+ } satisfies HeaderOptions;
189
+ ```
190
+
191
+ The description must start with a non-whitespace character and contain at least ten
192
+ characters. Running `oxlint --fix` on a file without a header inserts:
193
+
194
+ ```ts
195
+ // Owner: Acme
196
+ // Description:
197
+
198
+ export {};
199
+ ```
200
+
201
+ The generated description line retains the template’s space after the colon.
202
+ Successful insertion still leaves a lint error for `description`, so the CLI exits
203
+ unsuccessfully. Fill in a valid description, then lint again to pass. An invalid
204
+ nonempty replacement such as `TODO` works the same way.
205
+
206
+ Repeated fixes preserve the scaffold and keep reporting the incomplete field
207
+ without offering a no-op edit. Existing unfinished text such as `WIP` is preserved
208
+ rather than reset to the replacement. Other independently safe repairs, such as
209
+ correcting the owner, still apply while the description remains incomplete. Valid
210
+ fields, including historical years, stay unchanged. Ambiguous field boundaries
211
+ still require manual review.
212
+
213
+ ## Permitted leading comments
214
+
215
+ Use `allowedLeadingComments` to preserve explicitly permitted directives or pragmas
216
+ before the required header:
217
+
218
+ ```ts
219
+ const header = {
220
+ template: '/* Acme */',
221
+ allowedLeadingComments: ['// @ts-check', { pattern: '/\\*\\* @jest-environment [\\s\\S]*?\\*/' }],
222
+ } satisfies HeaderOptions;
223
+ ```
224
+
225
+ The default is an empty allowlist. Entries are complete literal comments or objects
226
+ with a regex `pattern` string. Patterns match the entire comment, including its
227
+ delimiters, with no flags; use explicit multiline expressions when needed. Literal
228
+ and source CRLF are normalized to LF for matching, while original prefix bytes are
229
+ preserved. Malformed literals, regexes, or option shapes are configuration errors.
230
+
231
+ A matching unit is one block comment or a consecutive `//` group. Headstamp never
232
+ splits a group to accept only some of its lines. Separate an existing line-comment
233
+ prefix and line-comment header with a blank line. Insertion adds the newline(s)
234
+ needed to keep these groups separate on subsequent runs.
235
+
236
+ Only the opening prefix after any BOM/shebang is considered. The required header
237
+ takes precedence over a matching allowlist entry, including when its fields need
238
+ repair. An ambiguous header remains diagnostic-only. Unknown comments stop the
239
+ search without autofix; source code ends the prefix, and later comments are not
240
+ searched for a header.
241
+
242
+ Permitted comments are never generated, repaired, or reordered. Their text and
243
+ spacing are preserved, including whitespace before an existing or inserted header.
244
+ Insertion adds a separator only when needed. Prefix-only files receive a header
245
+ with one final newline. Source indentation, historical fields, and unfinished
246
+ human input remain protected, and repeated fixes converge.
247
+
248
+ The allowlist does not change spacing before the header; `blankLinesAfter` controls
249
+ only the boundary after it.
250
+ This rule preserves source LF/CRLF; it does not read EditorConfig or convert the
251
+ rest of a file's line endings.
252
+
253
+ ## Spacing after the header
254
+
255
+ Set `blankLinesAfter` to the exact number of empty lines required before subsequent
256
+ content. It defaults to `1`; values must be nonnegative safe integers.
257
+
258
+ ```ts
259
+ const header = {
260
+ template: '/* Acme */',
261
+ blankLinesAfter: 0,
262
+ } satisfies HeaderOptions;
263
+ ```
264
+
265
+ Zero places source on the following line without an empty line. A value of `3`
266
+ requires three empty lines. Fixes add missing or remove excess lines, preserving
267
+ source indentation and LF/CRLF. Empty and header-only files always end with one
268
+ final newline, regardless of the configured count. Prefix spacing stays unchanged.
269
+
270
+ A zero count cannot safely join a `//` header to following `//` documentation:
271
+ that would merge separate comment groups on the next lint run. Headstamp reports
272
+ this conflict without changing that separator. Use a block header or at least one
273
+ blank line. Independently safe field repairs still apply.
274
+
275
+ Spacing fixes can also apply while required scaffold fields are incomplete. The
276
+ unfinished text and its lint errors remain until valid human input is supplied.
277
+ Repeated fixes converge and do not offer no-op edits.
278
+
279
+ ## Header behavior
280
+
281
+ - Templates contain exactly one complete `/* ... */` comment or consecutive `//`
282
+ lines. Include the delimiters and desired internal whitespace. Trailing template
283
+ newlines are ignored; LF and CRLF templates match either source convention.
284
+ - Literal header text must match exactly, including internal whitespace. Placeholder
285
+ values must match their patterns. Recognized invalid fields may be repaired as
286
+ described above. Changed literal text or an unrecognized opening comment produces
287
+ a diagnostic without autofix.
288
+ Consecutive opening `//` lines form one comment group: an extra line in that group
289
+ requires manual review.
290
+ - `oxlint --fix` inserts a missing header after any shebang and permitted opening
291
+ comments, preserving a BOM, source code, and separate later comments.
292
+ - Headers get exactly `blankLinesAfter` empty lines before subsequent content
293
+ (default `1`) when the edit preserves separate comment groups. Indentation of
294
+ subsequent content is retained. Without a permitted prefix, leading whitespace
295
+ before the header is removed; prefix spacing is preserved when present.
296
+ - Empty, whitespace-only, and header-only files end with the header and exactly one
297
+ final newline. They do not receive an empty trailing paragraph.
298
+ - Edits use the first LF or CRLF convention present in the source, defaulting to LF
299
+ when none exists. They do not rewrite the rest of the file.
300
+ - Repeated fixes converge and never offer an edit that makes no change.
301
+ - Invalid options or malformed templates fail rather than inserting source code
302
+ from an invalid template.
303
+
304
+ Oxlint 1.88.0 rejects a BOM immediately followed by a shebang during parsing, before
305
+ plugins run. BOM-only and shebang-only inputs are supported and tested separately;
306
+ Headstamp cannot fix a file that oxlint cannot parse.
307
+
308
+ ## Development
309
+
310
+ ```sh
311
+ npm run typecheck
312
+ npm run test:rule
313
+ npm test
314
+ npm run lint
315
+ npm run format:check
316
+ ```
317
+
318
+ Run `npm run format` to apply Oxfmt formatting across the repository. EditorConfig
319
+ sets UTF-8, LF, final newlines, two-space indentation, and a 120-column target.
320
+ The typed Oxfmt config adds single quotes, trailing commas, and import/package
321
+ sorting. Generated files and local state are excluded through the Toptal-generated
322
+ Git ignore rules and the project's additional exclusions.
323
+
324
+ GitHub Actions checks formatting and lint on pull requests and pushes to the main
325
+ branch. A Node 22.18, 24, and 26 matrix runs strict typechecking, the package build,
326
+ all tests, and package-content inspection. CI checks formatting without rewriting
327
+ files. Publishing a GitHub release triggers a separate npm release workflow after
328
+ those same checks; manual release-workflow runs prepare an artifact without
329
+ publishing. See [RELEASING.md](RELEASING.md) for bootstrap, trusted-publisher setup,
330
+ and version/tag requirements.
331
+
332
+ Tests use oxlint's public rule-testing API and real CLI runs. CLI tests exercise
333
+ TypeScript configuration and exported types, external templates, supported source
334
+ languages, overrides/ignores, distinct configurations, and repeated fixes.
335
+ Placeholder tests cover historical values, whole-value matching, literal replacement
336
+ characters, malformed configuration, ambiguity, and source preservation.
337
+
338
+ Implementation and tests are written in TypeScript. Both test commands strictly
339
+ typecheck source and tests, then run the TypeScript tests with Node's built-in type
340
+ stripping. The full suite builds the package first for CLI integration. Only plugin
341
+ source is emitted to the package; tests are never included in the build output.
342
+
343
+ Package output is ESM JavaScript plus TypeScript declarations. Build artifacts and
344
+ development-only files are excluded from the published package surface; inspect it
345
+ 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,58 @@
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.2",
4
+ "description": "Oxlint-first header templates, regex validation, and safe insertion.",
5
+ "keywords": [
6
+ "autofix",
7
+ "copyright-header",
8
+ "file-header",
9
+ "header-template",
10
+ "javascript",
11
+ "license-header",
12
+ "oxlint",
13
+ "oxlint-plugin",
14
+ "typescript"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/rm-industries/headstamp.git"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "registry": "https://registry.npmjs.org"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "typecheck": "tsc --project tsconfig.test.json",
39
+ "test": "npm run typecheck && npm run build && node --test test/*.test.ts",
40
+ "test:rule": "npm run typecheck && node --test test/header.test.ts",
41
+ "lint": "oxlint src test scripts oxfmt.config.ts",
42
+ "prepack": "npm run build",
43
+ "format:check": "oxfmt --check .",
44
+ "format": "oxfmt --write ."
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.20.5",
48
+ "oxfmt": "^0.78.0",
49
+ "oxlint": "^1.93.0",
50
+ "typescript": "^7.0.2"
51
+ },
52
+ "peerDependencies": {
53
+ "oxlint": "^1.88.0"
54
+ },
55
+ "engines": {
56
+ "node": "^22.18.0 || >=24.0.0"
57
+ }
58
+ }