diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c5288..de29261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,22 @@ All notable changes to **basou** are recorded here. The project follows a measure is not measured, `not_found` names it as `measures["site.pages"]`, as the difference from the previous record already did, so it is not read as a measure `pages` inside `site`. +- **A registered component can name the files that mark it (`marker`, from + `board_version: 3`, experimental).** A directory no built-in marker finds, + such as one of content or of scripts, was gone at every measure and fired + the axis review's (a) each time. `components..marker` is a glob from + the component's directory, written and checked as an `include` is (`*` + does not cross `/`; an absolute path, a `..` segment, a leading `:` and a + control character are refused). When a file the working tree lists under + that directory matches it, the component is found, with `marker` among its + kinds; when none does, it is gone, as before. `**` takes any file under + it, `*.py` a Python file right in it. The directories the built-in markers + skip (such as `node_modules`) are not skipped for it. A key that is a repo + itself, with nothing after the repo's name, takes no marker, and a key + that names a file finds nothing. The `components` method stays 2: a + registry without a marker measures as before. A component the built-in + markers already found has `marker` added to its kinds when it is given + one, which the next measure reports as a change of kinds. ### Changed diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 83e3da5..99cb7b6 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -837,6 +837,30 @@ describe("basou board measure", () => { ); }); + it("prints a component its own marker found with the kind marker", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await mkdir(join(repo, "content")); + await writeFile(join(repo, "content", "a.md"), "\n"); + const name = basename(repo); + const declared = JSON.parse(boardYaml([])) as Record; + await placeBoard( + repo, + JSON.stringify({ + ...declared, + board_version: 3, + components: { + [`${name}/content`]: { lane: ["core"], marker: "*.md" }, + [`${name}/tools`]: { lane: ["core"], marker: "*.py" }, + }, + }), + ); + const { out } = capture(); + await runBoardMeasure({}, ctx(repo)); + expect(out.join("\n")).toContain( + `\nComponents:\n 1 found, 0 unacknowledged, 1 gone\n ${name}/content marker\n ${name}/tools (gone)\n`, + ); + }); + it("lists the components by code point, whatever order JSON gives their keys", async () => { const repo = await workspace([ { path: ".", visibility: "private" }, diff --git a/packages/core/src/board/components.test.ts b/packages/core/src/board/components.test.ts index c6b90e5..883ba1c 100644 --- a/packages/core/src/board/components.test.ts +++ b/packages/core/src/board/components.test.ts @@ -138,3 +138,192 @@ describe("measureComponents against the previous record", () => { ]); }); }); + +describe("measureComponents: a registered component's own marker", () => { + const marked = ( + paths: string[], + registered: Record, + previous?: PreviousComponents, + ) => + measureComponents({ + repos: ["."], + names: new Map([[".", "app"]]), + worktreeOf: tree(paths), + registered, + ...(previous === undefined ? {} : { previous }), + }); + + it("finds a registered directory when a file under it matches its marker from there", async () => { + const { components, notFound } = await marked( + [ + "content/a.md", + "content/deep/b.md", + "tools/run.py", + "nested/sub/x.py", + "lib/x.txt", + "docs/README", + "web/package.json", + ], + { + "app/content": { marker: "**" }, + "app/tools": { marker: "*.py" }, + // '*' does not cross '/'. + "app/nested": { marker: "*.py" }, + "app/lib": { marker: "*.md" }, + "app/web": { marker: "package.json" }, + "app/docs": {}, + }, + ); + expect(components.found).toEqual({ + "app/content": { kinds: ["marker"], status: "known" }, + "app/tools": { kinds: ["marker"], status: "known" }, + // Beside what a built-in marker finds there. + "app/web": { kinds: ["manifest", "marker"], status: "known" }, + }); + expect(components.unacknowledged).toEqual([]); + expect(components.gone).toEqual(["app/docs", "app/lib", "app/nested"]); + expect(notFound).toEqual([]); + }); + + it("matches as an include does, by byte, from the component's directory", async () => { + const { components } = await marked( + [ + "a/deep/x.txt", + "b/x/y/z.md", + bytes("c/\u00e9.csv"), + bytes("\u30c7\u30fc\u30bf/rows.csv"), + "e/.hidden", + "f/[x].txt", + ], + { + // A literal path takes in what is under it. + "app/a": { marker: "deep" }, + "app/b": { marker: "x/**/*.md" }, + // '?' is one byte, and the name is two. + "app/c": { marker: "?.csv" }, + "app/\u30c7\u30fc\u30bf": { marker: "*.csv" }, + "app/e": { marker: "*" }, + "app/f": { marker: "\\[x\\].txt" }, + }, + ); + expect(Object.keys(components.found ?? {})).toEqual([ + "app/a", + "app/b", + "app/e", + "app/f", + "app/\u30c7\u30fc\u30bf", + ]); + expect(components.gone).toEqual(["app/c"]); + const two = await marked([bytes("c/\u00e9.csv")], { "app/c": { marker: "??.csv" } }); + expect(two.components.found).toEqual({ "app/c": { kinds: ["marker"], status: "known" } }); + }); + + it("finds nothing for a key that names a file, a repo itself or no repo, or only starts like a directory", async () => { + const { components } = await marked(["README.md", "content/a.md", "contents.txt", "x/y/z.md"], { + "app/README.md": { marker: "**" }, + "app/con": { marker: "**" }, + "app/x/y/z.md": { marker: "**" }, + app: { marker: "**" }, + "app/": { marker: "**" }, + "other/content": { marker: "**" }, + }); + expect(components.found).toEqual({}); + expect(components.gone).toEqual([ + "app", + "app/", + "app/README.md", + "app/con", + "app/x/y/z.md", + "other/content", + ]); + }); + + it("does not skip the directories the built-in markers skip", async () => { + const { components } = await marked( + ["vendor/lib/a.php", "web/node_modules/x/index.js", "web/dist/package.json"], + { "app/vendor/lib": { marker: "*.php" }, "app/web": { marker: "**" } }, + ); + expect(components.found).toEqual({ + "app/vendor/lib": { kinds: ["marker"], status: "known" }, + "app/web": { kinds: ["marker"], status: "known" }, + }); + }); + + it("looks in the repo the key names", async () => { + const trees: Record = { ".": ["data/a.md"], "../site": ["pages/b.md"] }; + const { components } = await measureComponents({ + repos: [".", "../site"], + names: new Map([ + [".", "app"], + ["../site", "site"], + ]), + worktreeOf: (repo) => tree(trees[repo] ?? [])(), + registered: { + "app/data": { marker: "*.md" }, + "app/pages": { marker: "*.md" }, + "site/pages": { marker: "*.md" }, + "site/data": { marker: "*.md" }, + }, + }); + expect(Object.keys(components.found ?? {})).toEqual(["app/data", "site/pages"]); + expect(components.gone).toEqual(["app/pages", "site/data"]); + }); + + it("is a kind like any other against the previous record", async () => { + const { components } = await marked( + ["web/package.json", "content/a.md", "tools/x.sh"], + { "app/web": { marker: "**" }, "app/content": { marker: "*.md" }, "app/tools": {} }, + { + status: "found", + found: { + "app/web": { kinds: ["manifest"] }, + "app/content": { kinds: ["marker"] }, + "app/tools": { kinds: ["marker"] }, + }, + }, + ); + expect(components.kind_changed).toEqual([ + { key: "app/web", before: ["manifest"], after: ["manifest", "marker"] }, + ]); + // Its marker taken off, a directory no built-in marker finds is gone. + expect(components.gone).toEqual(["app/tools"]); + }); + + it("matches a name that is not valid UTF-8 as its bytes are", async () => { + const { components } = await marked(["g/\xff.csv", "h/\xff.csv"], { + "app/g": { marker: "?.csv" }, + "app/h": { marker: "??.csv" }, + }); + expect(Object.keys(components.found ?? {})).toEqual(["app/g"]); + expect(components.gone).toEqual(["app/h"]); + }); + + it("reads the path of a key as written, not as a glob", async () => { + const { components } = await marked(["[slug]/a.md", "x/a.md", "s/a.md"], { + "app/[slug]": { marker: "*.md" }, + "app/*": { marker: "*.md" }, + "app/[s]": { marker: "*.md" }, + }); + expect(Object.keys(components.found ?? {})).toEqual(["app/[slug]"]); + expect(components.gone).toEqual(["app/*", "app/[s]"]); + }); + + it("finds nothing for a key whose path git would not write, which is not normalized", async () => { + const { components } = await marked(["content/a.md"], { + "app/content/": { marker: "**" }, + "app//content": { marker: "**" }, + "app/./content": { marker: "**" }, + }); + expect(components.found).toEqual({}); + expect(components.gone).toEqual(["app/./content", "app//content", "app/content/"]); + }); + + it("finds nothing for a key whose path is not well-formed text", async () => { + const { components } = await marked([bytes("\ufffd/a.md")], { + "app/\ud800": { marker: "**" }, + "app/\ufffd": { marker: "**" }, + }); + expect(Object.keys(components.found ?? {})).toEqual(["app/\ufffd"]); + expect(components.gone).toEqual(["app/\ud800"]); + }); +}); diff --git a/packages/core/src/board/components.ts b/packages/core/src/board/components.ts index dee349c..528e14f 100644 --- a/packages/core/src/board/components.ts +++ b/packages/core/src/board/components.ts @@ -1,14 +1,18 @@ -import { fromBytes } from "./glob.js"; +import { compileGlob, fromBytes, toBytes } from "./glob.js"; import type { PreviousOutcome } from "./previous.js"; import type { RepoScopeResult } from "./scope.js"; /** * The version of how the `components` section measures. Raised whenever a * value of the section would change for the same repositories, declaration - * and previous record, as when a rule for a marker changes. + * and previous record, as when a rule for a marker changes. A component's + * own `marker` did not raise it: a registry without one measures as before. */ export const BOARD_COMPONENTS_METHOD = 2; +// The kind a component is found as when its own marker finds a file. +const MARKER_KIND = "marker"; + // A file under one of these directories is not a marker. const SKIP = new Set([ "node_modules", @@ -62,6 +66,13 @@ export type BoardComponentChange = { key: string; before: string[]; after: strin * `components`. A key is the name of the directory the repository is in, * then the directory of the component inside it. * + * A registered component with a `marker` is also found, with the kind + * `marker`, when a file the working tree lists under its directory matches + * the marker from there, as an `include` matches from the repository's root; + * the built-in markers' skipped directories (such as `node_modules`) are not + * skipped for it. A key that names a file, or no directory of a repository, + * finds nothing. + * * Every value is null, with one entry at `components`, when a repository * cannot be opened, git could not list all of its working tree, two * repositories are in directories of the same name, or a component's @@ -102,7 +113,7 @@ export type ComponentsInput = { /** The working tree of a repository, by its path. */ worktreeOf: (repo: string) => Promise; /** The declaration's components, by key. */ - registered: Readonly>; + registered: Readonly>; /** The components of the previous record (default: there is none). */ previous?: PreviousComponents; }; @@ -133,6 +144,13 @@ export async function measureComponents(input: ComponentsInput): Promise<{ } const kinds = new Map>(); + const mark = (key: string, kind: string) => { + const found = kinds.get(key) ?? new Set(); + found.add(kind); + kinds.set(key, found); + }; + // The files of each repository's working tree, by the name of its directory. + const listed = new Map>(); let undecodable: string | undefined; for (const [name, path] of byName) { const opened = await input.worktreeOf(path); @@ -145,15 +163,33 @@ export async function measureComponents(input: ComponentsInput): Promise<{ undecodable ??= `in the repo '${path}', the directory '${fromBytes(dir)}' is not a valid UTF-8 name, so its component cannot be told apart from others`; return; } - const key = text === "" ? name : `${name}/${text}`; - const found = kinds.get(key) ?? new Set(); - found.add(kind); - kinds.set(key, found); + mark(text === "" ? name : `${name}/${text}`, kind); }; for (const file of opened.scope.entries.keys()) classify(file, add); + listed.set(name, opened.scope.entries); } if (undecodable !== undefined) return unmeasured(undecodable); + // A registered component's own marker, matched from its directory. + for (const [key, component] of Object.entries(input.registered)) { + const slash = key.indexOf("/"); + if (component.marker === undefined || slash === -1) continue; + const files = listed.get(key.slice(0, slash)); + if (files === undefined) continue; + const dir = key.slice(slash + 1); + // A path that is not well-formed text (a lone surrogate) names no + // directory: its bytes would be those of U+FFFD. + if (fromBytes(toBytes(dir)) !== dir) continue; + const under = `${toBytes(dir)}/`; + const matches = compileGlob(component.marker); + for (const file of files.keys()) { + if (file.startsWith(under) && matches(file.slice(under.length))) { + mark(key, MARKER_KIND); + break; + } + } + } + const registered = new Set(Object.keys(input.registered)); const previous = input.previous ?? { status: "none" }; const before = previous.status === "found" ? previous.found : {}; diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index 5ee47f2..02b35dc 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -2248,3 +2248,154 @@ describe("parseBoardDeclaration: what version 3 reads, in a board of version 1 o } }); }); + +describe("parseBoardDeclaration: board_version 3, a component's marker", () => { + const withComponents = (components: Record, version = 3) => ({ + ...board(), + board_version: version, + components, + }); + const markerOf = (marker: unknown) => + parse(withComponents({ "basou/tools": { lane: "-", note: "scripts", marker } })); + + it("reads a marker of a directory inside a repo, and none where none is written", () => { + const result = parse( + withComponents({ + "basou/content": { lane: ["saddle"], note: "data", marker: "**" }, + "basou/tools": { lane: "-", note: "scripts", marker: "*.py" }, + "basou/packages/sdk": { lane: ["saddle"] }, + // A key whose repo the manifest does not declare is not refused. + "elsewhere/x": { lane: ["saddle"], marker: "." }, + }), + ); + if (!result.ok) throw new Error(result.errors.join("\n")); + const { components } = result.declaration; + expect(components["basou/content"]?.marker).toBe("**"); + expect(components["basou/tools"]?.marker).toBe("*.py"); + expect(components["elsewhere/x"]?.marker).toBe("."); + expect(Object.hasOwn(components["basou/packages/sdk"] as object, "marker")).toBe(false); + }); + + it("refuses a marker that would leave the component's directory, as an include is refused", () => { + const relative = "must be relative to the component's directory"; + for (const [marker, message] of [ + ["", "must be a non-empty path"], + ["/x", relative], + ["\\\\server\\share", relative], + ["C:/x", `${relative} (a leading 'x:' reads as a Windows drive)`], + ["x:y.md", `${relative} (a leading 'x:' reads as a Windows drive)`], + ["../x", "must not contain a '..' segment"], + ["a/../../x", "must not contain a '..' segment"], + ["a\\..\\x", "must not contain a '..' segment"], + ["..", "must not contain a '..' segment"], + [":(top)*.md", "must not start with ':' (pathspec magic)"], + ["a\u0007b", "must not contain control characters"], + ["a\u0085b", "must not contain control characters"], + ] as const) { + expect(errorsOf(markerOf(marker)), marker).toEqual([ + `components["basou/tools"].marker: ${message}`, + ]); + } + expect(errorsOf(markerOf(1))).toEqual([ + 'components["basou/tools"].marker: Invalid input: expected string, received number', + ]); + // As in an include: '..' inside a name, and '.' as a directory. + for (const marker of ["a..b/*.md", "./*.md", "."]) { + expect(markerOf(marker).ok, marker).toBe(true); + } + }); + + it("refuses a marker on a key that is a repo itself", () => { + const itself = + "only a directory inside a repo takes a marker, keyed '/'; this key is a repo itself"; + expect( + errorsOf( + parse( + withComponents({ + basou: { lane: ["saddle"], marker: "**" }, + "basou/": { lane: ["saddle"], marker: "**" }, + "basou/x": { lane: ["saddle"], marker: "**" }, + other: { lane: ["saddle"] }, + }), + ), + ), + ).toEqual([`components.basou.marker: ${itself}`, `components["basou/"].marker: ${itself}`]); + // Said even when the marker is itself refused. + expect( + errorsOf(parse(withComponents({ basou: { lane: ["saddle"], marker: "../x" } }))), + ).toEqual([ + "components.basou.marker: must not contain a '..' segment", + `components.basou.marker: ${itself}`, + ]); + }); + + it("says that version 3 reads a marker, in a board of version 1 or 2, and holds it against the key", () => { + for (const version of [1, 2]) { + const doc = version === 1 ? board() : boardV2(); + doc.components = { + "basou/tools": { lane: "-", note: "scripts", marker: "*.py" }, + basou: { lane: "-", note: "the root", marker: "**" }, + "basou/packages/sdk": { lane: ["saddle"] }, + }; + const told = (where: string) => + `${where}: 'marker' is read from board_version 3 on; this file is board_version ${version}`; + expect(errorsOf(parse(doc))).toEqual([ + told('components["basou/tools"]'), + told("components.basou"), + "components.basou.marker: only a directory inside a repo takes a marker, keyed '/'; this key is a repo itself", + ]); + // A key no version reads is still unknown. + doc.components = { "basou/tools": { lane: "-", note: "scripts", markers: "*.py" } }; + expect(errorsOf(parse(doc))).toEqual([`components["basou/tools"]: unknown key 'markers'`]); + } + }); + + it("says only that an empty key is empty, with a marker or without", () => { + expect( + errorsOf( + parse( + withComponents({ + "": { lane: ["saddle"], marker: "**" }, + " ": { lane: ["saddle"], marker: "**" }, + }), + ), + ), + ).toEqual([ + 'components[""]: a component key must be a non-empty path', + 'components[" "]: a component key must be a non-empty path', + ]); + }); + + it("takes a marker on any key with a path after the repo's name, as git would write it or not", () => { + for (const key of ["basou/x/", "basou//x", "basou/./x", "/x"]) { + expect(parse(withComponents({ [key]: { lane: ["saddle"], marker: "**" } })).ok, key).toBe( + true, + ); + } + }); + + it("says a repo itself takes no marker whatever the marker is, after the note and before the lanes", () => { + const itself = + "only a directory inside a repo takes a marker, keyed '/'; this key is a repo itself"; + expect(errorsOf(parse(withComponents({ basou: { lane: ["saddle"], marker: 1 } })))).toEqual([ + "components.basou.marker: Invalid input: expected string, received number", + `components.basou.marker: ${itself}`, + ]); + expect( + errorsOf( + parse( + withComponents({ + basou: { lane: "-", marker: "**" }, + "basou/": { lane: ["nope", "saddle", "saddle"], marker: "**" }, + }), + ), + ), + ).toEqual([ + "components.basou.note: a component with lane '-' needs a note saying why", + `components.basou.marker: ${itself}`, + `components["basou/"].marker: ${itself}`, + `components["basou/"].lane[0]: no lane has the id 'nope'`, + `components["basou/"].lane[2]: 'saddle' is listed twice`, + ]); + }); +}); diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index 19d093e..8b3d452 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -21,8 +21,9 @@ import { TaskStatusSchema } from "../schemas/task.schema.js"; * observations, what to mind when making any observation (`notes`), the * name of a file (`file_name`), the paths of files (`file_paths`) and a sum * of integers and other measures (`total`) as measures, a measure drawn in - * more than one lane (`lane` as a list), and a measure id and an observation - * key of words joined by '.'. + * more than one lane (`lane` as a list), a measure id and an observation key + * of words joined by '.', and the files that mark a registered component + * (`components..marker`). */ export const BOARD_VERSION = 3; @@ -110,13 +111,14 @@ const timeZoneText = z.string().refine(isBoardTimeZone, { // A path or glob inside a repository. It may not leave the repository, so an // absolute path and a `..` segment are refused; a leading ':' would be read // as git pathspec magic, which the measures do not take from the declaration. -// Why a path or glob would leave the repository, or undefined. -function repoPathProblem(s: string): string | undefined { +// Why a path or glob would leave the repository, or undefined. `from` names +// what it is relative to. +function repoPathProblem(s: string, from = "the repository"): string | undefined { if (s.length === 0) return "must be a non-empty path"; if (hasControlCharacter(s)) return "must not contain control characters"; - if (s.startsWith("/") || s.startsWith("\\")) return "must be relative to the repository"; + if (s.startsWith("/") || s.startsWith("\\")) return `must be relative to ${from}`; if (/^[A-Za-z]:/.test(s)) { - return "must be relative to the repository (a leading 'x:' reads as a Windows drive)"; + return `must be relative to ${from} (a leading 'x:' reads as a Windows drive)`; } if (s.split(/[/\\]/).includes("..")) return "must not contain a '..' segment"; if (s.startsWith(":")) return "must not start with ':' (pathspec magic)"; @@ -128,6 +130,13 @@ const repoPath = z.string().superRefine((s, ctx) => { if (problem !== undefined) ctx.addIssue({ code: "custom", message: problem }); }); +// A component's marker: a glob from the component's directory, held to what +// an `include` is, so it cannot reach outside that directory either. +const markerPath = z.string().superRefine((s, ctx) => { + const problem = repoPathProblem(s, "the component's directory"); + if (problem !== undefined) ctx.addIssue({ code: "custom", message: problem }); +}); + const regexText = z.string().superRefine((s, ctx) => { if (s.length === 0) { ctx.addIssue({ code: "custom", message: "must be a non-empty regular expression" }); @@ -629,10 +638,34 @@ const boardVersionText = z.literal(BOARD_VERSIONS, { error: `must be a board version this basou reads (${READ_VERSIONS})`, }); +// A component of the registry: the lanes it counts in, or '-' with a note. +const componentFields = { + lane: z.union( + [ + z.literal("-"), + z.array(idText).min(1, { error: "must list at least one lane id, or be '-'" }), + ], + { + error: "must be '-' or a non-empty list of lane ids", + }, + ), + note: nonEmptyText.optional(), +}; + +const componentSchema = z.strictObject(componentFields); + +// From version 3, a component may name the files that mark it, for a +// directory no built-in marker finds. +const componentSchemaV3 = z.strictObject({ ...componentFields, marker: markerPath.optional() }); + // The keys every version has after its stages, in the order a problem with -// them is reported, with the measures the version reads and the id a ratio -// names a measure by. -const boardFieldsOf = (measure: M, measureRef: z.ZodString) => ({ +// them is reported, with the measures the version reads, the id a ratio +// names a measure by and the components it registers. +const boardFieldsOf = ( + measure: M, + measureRef: z.ZodString, + component: C, +) => ({ lanes: z .array( z.strictObject({ @@ -658,18 +691,7 @@ const boardFieldsOf = (measure: M, measureRef: z.ZodString) .record( // An empty key is reported by the cross-check, which can say what it is. z.string(), - z.strictObject({ - lane: z.union( - [ - z.literal("-"), - z.array(idText).min(1, { error: "must list at least one lane id, or be '-'" }), - ], - { - error: "must be '-' or a non-empty list of lane ids", - }, - ), - note: nonEmptyText.optional(), - }), + component, ) .default({}), axis: z.strictObject({ @@ -690,14 +712,14 @@ const boardSchemaV1 = z.strictObject({ board_version: boardVersionText, title: nonEmptyText, stages: stagesOf(stageSchemaV1), - ...boardFieldsOf(measureSchemaV1, idText), + ...boardFieldsOf(measureSchemaV1, idText, componentSchema), }); const boardSchemaV2 = z.strictObject({ board_version: boardVersionText, title: nonEmptyText, stages: stagesOf(stageSchemaV2), - ...boardFieldsOf(measureSchemaV1, idText), + ...boardFieldsOf(measureSchemaV1, idText, componentSchema), observe: z.array(observeSchemaV2).default([]), }); @@ -705,7 +727,7 @@ const boardSchemaV3 = z.strictObject({ board_version: boardVersionText, title: nonEmptyText, stages: stagesOf(stageSchemaV2), - ...boardFieldsOf(measureSchemaV3, dottedIdText), + ...boardFieldsOf(measureSchemaV3, dottedIdText, componentSchemaV3), observe: z.array(observeSchemaV3).default([]), }); @@ -714,8 +736,8 @@ const boardSchemaV3 = z.strictObject({ * observations and no stage has `look` or `notes`, and one of version 1 or 2 * has no `http` or `github_check` observation, no observation with `notes`, * no `file_name`, `file_paths` or `total` measure, no measure of more than - * one lane, and no measure id or observation key with '.'. A measure's - * `lane` is a list whatever the version. + * one lane, no measure id or observation key with '.', and no component with + * a `marker`. A measure's `lane` is a list whatever the version. */ export type BoardDeclaration = z.output; export type BoardMeasure = BoardDeclaration["measures"][number]; @@ -1114,9 +1136,9 @@ function captureGroups(pattern: string): number | undefined { // shape check already refuses is skipped here: a list that is not a list, a // key the kind does not take, a value of the wrong type or form. What a later // version reads is not skipped in a board of version 1 or 2 (a measure's list -// of lanes, a total's terms, a measure id with '.'): it is held against the -// rest of the board as the later version holds it, beside the line saying -// which version reads it. +// of lanes, a total's terms, a measure id with '.', a component's marker): it +// is held against the rest of the board as the later version holds it, +// beside the line saying which version reads it. function crossCheck(raw: Record, context: BoardDeclarationContext): string[] { const errors: string[] = []; @@ -1270,6 +1292,19 @@ function crossCheck(raw: Record, context: BoardDeclarationConte if (component.lane === "-" && component.note === undefined) { errors.push(`${at}.note: a component with lane '-' needs a note saying why`); } + // A key with nothing after the repo's name is the repo itself, which + // the built-in markers already look for in the whole of it. An empty + // key is no repo, and is reported above. + const slash = key.indexOf("/"); + if ( + component.marker !== undefined && + key.trim() !== "" && + (slash === -1 || slash === key.length - 1) + ) { + errors.push( + `${at}.marker: only a directory inside a repo takes a marker, keyed '/'; this key is a repo itself`, + ); + } if (laneIds !== undefined && Array.isArray(component.lane)) { const seen = new Set(); component.lane.forEach((lane, j) => { diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 3105789..40087d4 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -332,6 +332,16 @@ describe("boardGuide: a board declared", () => { ); }); + it("says a gone directory no built-in marker finds may take a marker, and not only to silence (a)", () => { + const guide = boardGuide(guideInput()); + expect(guide).toContain( + "If it is still there and no built-in marker finds it (a directory of content or of scripts), give it a `marker` (board_version 3 on): a glob from its directory, written as an `include` is, of the files that mark it (`**` for any file under it, `*.py` for a Python file right in it). It is then found, with the kind `marker`, while such a file is there; a repo itself takes none.", + ); + expect(guide).toContain( + "- Change the axis silently, or register a component, or give one a marker, only to silence (a).", + ); + }); + it("leaves the model to be named, in the commands and the template, so neither runs as printed", () => { const guide = boardGuide(guideInput()); const blocks = blocksIn(guide).join("\n"); @@ -638,6 +648,12 @@ describe("boardGuide: no board declared", () => { expect(guide).not.toContain("## Steps"); expect(guide).not.toContain("```json"); }); + + it("says a directory no built-in marker finds is registered with a marker of its own", () => { + expect(boardGuide(guideInput({ board: { status: "undeclared" } }))).toContain( + "A directory no built-in marker finds (content, scripts) is registered with a `marker` of its own: a glob, from it, of the files that mark it.", + ); + }); }); describe("boardGuide: the commands run", () => { diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 8e3223a..4df75ba 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -213,7 +213,7 @@ export function boardGuide(input: BoardGuideInput): string { "", "1. Each lane: does it still stand as who uses what? Do its stages' conditions fit what is built now? Merge, split, retire or keep, with one sentence why even for keep.", "2. Each unacknowledged component: does it change a lane's conditions, make a new lane, or count in no lane (and why)? For the first two, what to measure or observe for it.", - "3. Each gone component: done with, or a repo in another state? If done with, take it off the registry and fix the lane's conditions.", + "3. Each gone component: done with, or a repo in another state? If done with, take it off the registry and fix the lane's conditions. If it is still there and no built-in marker finds it (a directory of content or of scripts), give it a `marker` (board_version 3 on): a glob from its directory, written as an `include` is, of the files that mark it (`**` for any file under it, `*.py` for a Python file right in it). It is then found, with the kind `marker`, while such a file is there; a repo itself takes none.", "4. Do the decisions recorded hold anything that should move the axis (a new surface, a new base, something decided not to build)?", "5. If the last review was by another model: does anything in how the lanes are cut or worded need fixing now? Change nothing only to make it look new; change only what is worth losing the comparison with past records.", "", @@ -352,7 +352,7 @@ export function boardGuide(input: BoardGuideInput): string { "- Argue priorities: the board lists facts, and the operator's turns list what waits for a ruling.", "- Write an estimate as a measurement, or let a number you could not get pass as blank or 0.", "- Put money on the board, or read effort as progress: reach is shown by the matrix alone.", - "- Change the axis silently, or register a component only to silence (a).", + "- Change the axis silently, or register a component, or give one a marker, only to silence (a).", "- Write the board's HTML: basou draws it.", "- Touch another project's repos.", "", @@ -798,7 +798,7 @@ function undeclared( ); block(`${go} && basou board measure --model "$MODEL" > "$W/measure.txt"; echo "exit=$?"`); line( - '5. Register the components: at first every component the markers find is unacknowledged, and the axis review (a) fires. Give each the lanes it counts in, or `"-"` with a note saying why it counts in none.', + '5. Register the components: at first every component the markers find is unacknowledged, and the axis review (a) fires. Give each the lanes it counts in, or `"-"` with a note saying why it counts in none. A directory no built-in marker finds (content, scripts) is registered with a `marker` of its own: a glob, from it, of the files that mark it.', "6. Run `basou board guide` again: with a board declared, it prints the steps that judge and record it. The first record is the first review of the axis: give it an `axis_review`.", "", ); diff --git a/packages/core/src/board/init.test.ts b/packages/core/src/board/init.test.ts index 10927e5..c7aad08 100644 --- a/packages/core/src/board/init.test.ts +++ b/packages/core/src/board/init.test.ts @@ -76,6 +76,28 @@ describe("boardInitText", () => { ); }); + it("says, above an empty components, that a directory no built-in marker finds takes a marker", () => { + for (const [language, words] of [ + ["en", en], + ["ja", ja], + ] as const) { + const text = boardInitText({ + name: "shop", + language, + start: "2026-09-21", + startIsFirstSession: true, + timeZone: "UTC", + }); + expect(text).toContain(`# ${words.comments.components}\ncomponents: {}\n`); + } + expect(en.comments.components).toContain( + "A directory no built-in marker finds (content, scripts) is registered with a marker: a glob, from it, of the files that mark it.", + ); + expect(ja.comments.components).toContain( + "marker\uff08\u305d\u306e\u30c7\u30a3\u30ec\u30af\u30c8\u30ea\u304b\u3089\u898b\u305f\u3001\u5370\u306b\u3059\u308b\u30d5\u30a1\u30a4\u30eb\u306e glob\uff09\u3092\u4ed8\u3051\u3066\u767b\u9332\u3059\u308b", + ); + }); + it("writes the words in Japanese for a Japanese anchor", () => { const result = read( boardInitText({ diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index ca0f138..2427aae 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -4576,6 +4576,64 @@ describe("measureBoard: the axis section", () => { expect(later.axis.reasons.map((r) => r.trigger)).toEqual(["a", "b"]); }); + it("finds a registered directory by its own marker, in the working tree, so (a) does not fire", async () => { + const dir = await repo( + "app", + { ".gitignore": "ignored/\n", "package.json": "{}\n", "tools/run.py": "\n" }, + { "content/a.md": "\n", "ignored/b.md": "\n" }, + ); + const components = { + app: { lane: "-", note: "root" }, + "app/tools": { lane: "-", note: "scripts", marker: "*.py" }, + "app/content": { lane: "-", note: "data", marker: "**" }, + "app/ignored": { lane: "-", note: "ignored", marker: "**" }, + }; + const declaration = declare([], { ...SEEDED, board_version: 3, components }, ["app"]); + const judged = (m: BoardMeasurement) => ({ + found: m.components.found, + gone: m.components.gone, + reasons: m.axis.reasons, + }); + const m = await measure(declaration, NOW, ["app"]); + expect(judged(m)).toEqual({ + found: { + app: { kinds: ["manifest"], status: "known" }, + // Untracked, but not ignored. + "app/content": { kinds: ["marker"], status: "known" }, + "app/tools": { kinds: ["marker"], status: "known" }, + }, + // An ignored file is not one the working tree lists. + gone: ["app/ignored"], + reasons: [{ trigger: "a", detail: "components: 0 unacknowledged, 1 gone" }], + }); + // Without the ignored one, nothing fires. + const { "app/ignored": _ignored, ...kept } = components; + const quiet = await measure( + declare([], { ...SEEDED, board_version: 3, components: kept }, ["app"]), + NOW, + ["app"], + ); + expect(judged(quiet)).toMatchObject({ gone: [], reasons: [] }); + expect(quiet.axis.review_needed).toBe(false); + // The marker is in the declaration the digest covers. + const other = declare( + [], + { + ...SEEDED, + board_version: 3, + components: { ...kept, "app/tools": { lane: "-", note: "scripts", marker: "**" } }, + }, + ["app"], + ); + expect((await measure(other, NOW, ["app"])).digest).not.toBe(quiet.digest); + // Once the files are gone, so is the component. + await rm(join(dir, "content"), { recursive: true }); + expect((await measure(declaration, NOW, ["app"])).components.gone).toEqual([ + "app/content", + "app/ignored", + ]); + }); + it("counts the days to today in the effort's time zone", async () => { // 2026-11-26T16:00Z is 11-27 in Tokyo, 60 days after the review; 59 in UTC. const at = new Date("2026-11-26T16:00:00.000Z"); diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index 17f567a..4aedf4b 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -29,12 +29,14 @@ function isRecord(value: unknown): value is Record { * measure the name of a file (`file_name`), the paths of files * (`file_paths`), whose value in the measurement is a list of strings, or a * total of other measures and integers (`total`), draw a measure in more - * than one lane and name a measure or an observation with '.'; and in 3 a - * measure's `lane`, in the measurement and in the declaration, is always a - * list of lane ids, where 1 and 2 hold one id, and each repo of the - * measurement names the branch it is compared with (`base`), counts what - * is behind it as `behind`, where 1 and 2 have `behind_main`, and what is - * ahead of it (`ahead`), and says when it was last fetched (`fetched_at`). + * than one lane, name a measure or an observation with '.' and register a + * component with the files that mark it (`marker`), found then with the + * kind `marker` in the measurement; and in 3 a measure's `lane`, in the + * measurement and in the declaration, is always a list of lane ids, where 1 + * and 2 hold one id, and each repo of the measurement names the branch it is + * compared with (`base`), counts what is behind it as `behind`, where 1 and 2 + * have `behind_main`, and what is ahead of it (`ahead`), and says when it was + * last fetched (`fetched_at`). * Every record is written in the newest version, whatever the version of the * board it records, and every version is read. */ diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 9f754de..a1937ee 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1461,7 +1461,7 @@ const BOARD_INIT_EN: BoardInitStrings = { observe: "What to observe outside basou (npm_version, github_release, github_open_issues, github_open_prs, github_ci, github_check, page_version, http, manual), each with notes on what to mind when making it if need be. basou never makes these; the guide says how.", components: - "Left empty: the first measure names every component it finds, and the axis review registers each with its lanes or '-' and a note.", + "Left empty: the first measure names every component it finds, and the axis review registers each with its lanes or '-' and a note. A directory no built-in marker finds (content, scripts) is registered with a marker: a glob, from it, of the files that mark it.", axis: "Raise version when a lane is added or removed or a stage's meaning changes.", effort: "start is the day of the first session (today when there is none).", effortToday: @@ -1498,7 +1498,7 @@ const BOARD_INIT_JA: BoardInitStrings = { observe: "basou の外で観測するもの(npm_version・github_release・github_open_issues・github_open_prs・github_ci・github_check・page_version・http・manual)。取るときの注意があれば notes に書く。basou は取りに行かない。取り方は guide が出す。", components: - "空のままでよい。最初の measure が見つけた構成要素を全部名指しし、軸の見直しで各要素をレーンに(数えないなら '-' と note で)登録する。", + "空のままでよい。最初の measure が見つけた構成要素を全部名指しし、軸の見直しで各要素をレーンに(数えないなら '-' と note で)登録する。組み込みの印で見つからないディレクトリ(データ・スクリプト)は marker(そのディレクトリから見た、印にするファイルの glob)を付けて登録する。", axis: "レーンの増減や段の意味を変えたら version を上げる。", effort: "start は最初の session の日(session が無ければ今日)。", effortToday: