208 lines
8.7 KiB
JavaScript
208 lines
8.7 KiB
JavaScript
// 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)
|
||
}
|
||
}
|