Files

208 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
//
// The licence layout of this template (plan item AT5.1, spec section 14.3), as a tool and as the
// rules `kit check` and the tests read.
//
// node scripts/licence.mjs --check fail, naming each file, when the layout is not whole
// node scripts/licence.mjs --write add the missing headers and rewrite TEMPLATE-FILES
//
// What is the template's and what is the customer's:
//
// - Template files (this tool's `templateFiles`) carry the SPDX header `LicenseRef-ConusAI-Template-1.0`
// and are listed in TEMPLATE-FILES: every file of the scaffold except `modules/**` and `test/modules/**` (the
// customer's domain code and its app-level tests, which carry the customer's licence), `LICENSE` (the customer's own file, a stub
// until they fill it in), `pnpm-lock.yaml` (generated per bundle and then maintained by the
// customer) and `.conusai/template.json` (the upgrade record, written by the bundle).
// - A file that cannot hold a comment (JSON, an image, llms.txt) is listed and exempt from
// the header: `headerFor` answers null for it.
//
// `kit check` of @conusai-kit/cli reads the same two facts, TEMPLATE-FILES and the header, so a file a
// customer or an agent copies out of the scaffold without its header fails the gate.
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
import { dirname, join, relative, sep } from "node:path"
import { fileURLToPath } from "node:url"
export const SPDX_ID = "LicenseRef-ConusAI-Template-1.0"
export const SPDX_LINE = `SPDX-License-Identifier: ${SPDX_ID}`
export const LIST_FILE = "TEMPLATE-FILES"
/** Directories that are never part of the shipped template, at any depth. */
// `_generated`: Convex codegen output, written by a deploy and git-ignored.
const IGNORED_DIRS = new Set([
"node_modules",
".next",
"dist",
"test-results",
"playwright-report",
".logs",
".dev",
"_generated",
])
/** Files that are the customer's, or written by the bundle, and so are not the template's. */
const NOT_THE_TEMPLATES = new Set(["LICENSE", "pnpm-lock.yaml", ".conusai/template.json", ".env", ".env.local"])
const CUSTOMER_PREFIXES = ["modules/", "test/modules/"]
/** Files a tool writes: Next.js rewrites next-env.d.ts, TypeScript writes build info. They are not shipped. */
const GENERATED = (name) => name === "next-env.d.ts" || name.endsWith(".tsbuildinfo")
/** `//`-style comment types, by extension. */
const SLASH = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"])
const HASH = new Set([".yml", ".yaml", ".sh", ".npmrc", ".gitignore", ".dockerignore", ".example"])
const HASH_NAMES = new Set(["Dockerfile", ".npmrc", ".gitignore", ".dockerignore"])
const extensionOf = (path) => {
const name = path.slice(path.lastIndexOf("/") + 1)
const dot = name.lastIndexOf(".")
return dot <= 0 ? "" : name.slice(dot)
}
/**
* The comment that carries the header in this file, or null when the file cannot hold one. Plain text
* the licence layout itself writes (LICENSE-TEMPLATE, NOTICE, THIRD-PARTY-NOTICES, TEMPLATE-FILES) states
* its licence in its own words, so it is exempt too.
*/
export function headerFor(path) {
const name = path.slice(path.lastIndexOf("/") + 1)
const ext = extensionOf(path)
if (SLASH.has(ext)) return `// ${SPDX_LINE}`
if (ext === ".css") return `/* ${SPDX_LINE} */`
if (ext === ".sql") return `-- ${SPDX_LINE}`
if (ext === ".md") return `<!-- ${SPDX_LINE} -->`
if (HASH.has(ext) || HASH_NAMES.has(name) || name.startsWith(".env")) return `# ${SPDX_LINE}`
return null
}
/** Every file the template ships that is the template's, as sorted POSIX paths relative to `root`. */
export function templateFiles(root) {
const found = []
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!IGNORED_DIRS.has(entry.name)) walk(join(dir, entry.name))
} else if (entry.isFile() && !GENERATED(entry.name)) {
const rel = relative(root, join(dir, entry.name)).split(sep).join("/")
if (NOT_THE_TEMPLATES.has(rel) || CUSTOMER_PREFIXES.some((prefix) => rel.startsWith(prefix))) continue
found.push(rel)
}
}
}
walk(root)
return found.sort()
}
/** The text TEMPLATE-FILES holds for a set of paths. */
export function listText(paths) {
return [
"# The files of this template that ConusAI owns: the scaffold `kit template:upgrade` merges, and the",
`# files that carry the header \`${SPDX_ID}\` (LICENSE-TEMPLATE). One path per line.`,
"#",
"# Not listed, because they are yours: `modules/**` (your domain code), `test/modules/**` (its tests that",
"# need the whole app), `LICENSE` (your own licence), `pnpm-lock.yaml` (your own lock) and",
"# `.conusai/template.json` (the upgrade record). A file that",
"# cannot hold a comment (JSON, llms.txt) is listed and has no header. Regenerate with",
"# `node scripts/licence.mjs --write`.",
...paths,
"",
].join("\n")
}
/** The paths a TEMPLATE-FILES text lists. */
export function parseList(text) {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "" && !line.startsWith("#"))
}
/**
* The line the header sits on: after a shebang, and after the YAML frontmatter of a Markdown file that has one
* (an agent skill or command, `.claude/skills/<name>/SKILL.md`, must open with `---` for its tool to read it). A
* BOM or a directive's line is not skipped: the header leads. Keep frontmatter short: `kit check` looks for the
* header in a file's first eight lines.
*/
function headerLine(lines, path) {
if (lines[0]?.startsWith("#!")) return 1
if (extensionOf(path) === ".md" && lines[0]?.trim() === "---") {
const close = lines.indexOf("---", 1)
if (close > 0) return close + 1
}
return 0
}
/** Whether `text` carries the header on its header line (see `headerLine`). */
export function hasHeader(text, path) {
const header = headerFor(path)
if (header === null) return true
const lines = text.replace(/^/, "").split("\n")
return lines[headerLine(lines, path)]?.trim() === header
}
/** The text with the header added on its header line (see `headerLine`). */
export function withHeader(text, path) {
const header = headerFor(path)
if (header === null || hasHeader(text, path)) return text
const lines = text.split("\n")
lines.splice(headerLine(lines, path), 0, header)
return lines.join("\n")
}
/**
* Everything wrong with the licence layout under `root`, as one sentence each. Empty when whole.
*/
export function problems(root) {
const found = []
for (const required of ["LICENSE-TEMPLATE", "LICENSE", "NOTICE", LIST_FILE, "THIRD-PARTY-NOTICES"]) {
if (!existsSync(join(root, required))) found.push(`${required} is missing`)
}
const actual = templateFiles(root)
if (existsSync(join(root, LIST_FILE))) {
const listed = new Set(parseList(readFileSync(join(root, LIST_FILE), "utf8")))
for (const path of actual) if (!listed.has(path)) found.push(`${LIST_FILE} does not list ${path}`)
for (const path of listed)
if (!actual.includes(path)) found.push(`${LIST_FILE} lists ${path}, which is not a template file`)
}
for (const path of actual) {
const text = readFileSync(join(root, path), "utf8")
if (!hasHeader(text, path)) found.push(`${path} has no "${SPDX_LINE}" header`)
}
return found
}
/** Adds every missing header and rewrites TEMPLATE-FILES; answers the paths it changed. */
export function write(root) {
const changed = []
// The list first: it is itself one of the files.
const paths = [...new Set([...templateFiles(root), LIST_FILE])].sort()
writeFileSync(join(root, LIST_FILE), listText(paths))
for (const path of paths) {
const file = join(root, path)
if (!statSync(file).isFile()) continue
const before = readFileSync(file, "utf8")
const after = withHeader(before, path)
if (after !== before) {
writeFileSync(file, after)
changed.push(path)
}
}
return changed
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const root = join(dirname(fileURLToPath(import.meta.url)), "..")
const mode = process.argv[2]
if (mode === "--write") {
const changed = write(root)
console.log(
`licence: ${changed.length} file(s) changed; ${LIST_FILE} lists ${parseList(readFileSync(join(root, LIST_FILE), "utf8")).length}`,
)
} else if (mode === "--check") {
const found = problems(root)
for (const problem of found) console.error(`licence: ${problem}`)
if (found.length > 0) process.exit(1)
console.log("licence: the layout is whole")
} else {
console.error("usage: node scripts/licence.mjs --check | --write")
process.exit(2)
}
}