@sabaiway/agent-workflow-kit 7.4.0 → 7.6.0
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/CHANGELOG.md +79 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/procedures.md +2 -0
- package/references/modes/upgrade.md +2 -2
- package/references/scripts/spec-schema.mjs +37 -5
- package/references/scripts/spec-schema.test.mjs +67 -0
- package/tools/ensure-configs.mjs +5 -4
- package/tools/ensure-specs.mjs +22 -13
- package/tools/ensure-vocabulary.mjs +2 -2
- package/tools/procedures.mjs +24 -4
- package/tools/script-priors.mjs +8 -3
- package/tools/spec-check-cli.mjs +158 -0
- package/tools/spec-check-ops.mjs +181 -0
- package/tools/spec-check.mjs +381 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,85 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 7.6.0 — `spec-check`: the spec store judged against what the session SAYS it changed (AD-115)
|
|
8
|
+
|
|
9
|
+
The feature-spec layer gets its structural checker. The change source is EXPLICIT and never git — a
|
|
10
|
+
session states what it did, and the checker decides whether the store on disk says the same thing.
|
|
11
|
+
"Well-formed" keeps its ONE definition: every per-document verdict is RELAYED from the shipped
|
|
12
|
+
reader's 7.5.0 `structure` extraction, and this release adds no second parser.
|
|
13
|
+
|
|
14
|
+
- **`tools/spec-check-ops.mjs` — one accepted spelling per document.** Four verbs
|
|
15
|
+
(`add` | `modify` | `remove` | `rename=<old>:<new>`) over POSIX repo-relative `.md` paths inside
|
|
16
|
+
`docs/ai/specs/` whose every segment is D-schema shaped. Nothing is normalized away: `./x.md`,
|
|
17
|
+
`a//b.md`, `a/../b.md`, a backslash, a drive letter, an absolute path and a trailing-slash
|
|
18
|
+
directory all REFUSE rather than resolving to some other path — so a dedup can be an equality, and
|
|
19
|
+
because no accepted target can carry a `:` the rename separator is unambiguous. A path holds at
|
|
20
|
+
most ONE role (self-rename, fan-out, fan-in, chain and any two-role clash refuse); the store root
|
|
21
|
+
is never a target; any error empties the whole op list.
|
|
22
|
+
- **`tools/spec-check.mjs` — the judge, with no filesystem of its own.** `{read, probe, realpath,
|
|
23
|
+
list}` are injected and an uninjected one throws. The SESSION lane judges the closure of the
|
|
24
|
+
declared ops (targets plus each one's listing parent) on the post-state, the reader verdict, the
|
|
25
|
+
per-kind line cap, the scenario bindings (the marker must occur EXACTLY once in the file the
|
|
26
|
+
scenario names) and the containment of every path each document names. A `## Module` path must
|
|
27
|
+
also EXIST with the kind it declares — a `dir/` root resolving to a directory, a file-list entry
|
|
28
|
+
to a regular file — so a contract can no longer name code that is not there. `--all` judges the
|
|
29
|
+
whole store instead and adds the four questions no document can answer about itself: an unlisted
|
|
30
|
+
child (DISTINCT from an orphan), acyclicity, store-wide slug uniqueness and module overlap.
|
|
31
|
+
- **Fail-closed means PROVEN contained.** A path whose realpath does not resolve is never read.
|
|
32
|
+
Containment asks the platform's own path model, because a textual prefix test reads
|
|
33
|
+
`/repo\outside` as a child of `/repo` on POSIX and mis-reads a filesystem root in both directions.
|
|
34
|
+
Every listed edge is resolved, contained and probed before it may enter the reachability graph — a
|
|
35
|
+
phantom target would otherwise be marked reached and launder an orphan into a reached document.
|
|
36
|
+
The census admits a CLOSED set — plain directories and regular `.md` files — and states everything
|
|
37
|
+
else: an unlistable directory, one that resolves outside the root, any non-regular entry, and a
|
|
38
|
+
regular file that is not a spec document. A census that observed nothing keeps its own refusal
|
|
39
|
+
instead of collapsing into a usage error. The leaf read is descriptor-bound and no-follow.
|
|
40
|
+
- **`tools/spec-check-cli.mjs` — the IO shell.** `--op` (repeatable) unions `--ops-file`, `--all` is
|
|
41
|
+
exclusive of both, `--root` defaults to the cwd. The register is never defaulted: guessing which
|
|
42
|
+
file states the change set would attest a post-state nobody declared. Blank and `#` lines parse
|
|
43
|
+
away, CRLF reads like LF, and every other line is passed on UNTRIMMED so both op sources accept
|
|
44
|
+
exactly the same strings. Exit 0 clean, 1 findings, 2 usage.
|
|
45
|
+
- **`tools/procedures.mjs` — the advisor names it.** A `plan-execution` render now carries the
|
|
46
|
+
populated `spec-check` commands for both lanes and the session register they read, plus the
|
|
47
|
+
additive `specCheck` `--json` key. Advisory, like the finding-scope block beside it: nothing
|
|
48
|
+
records that it ran.
|
|
49
|
+
- **Proof.** Every suite authored RED first and observed failing before its module existed; the
|
|
50
|
+
D-scale gate measures the checker's OWN median (573 ms) beside the unchanged hook median against
|
|
51
|
+
the 1500 ms budget, so a slower checker can never pass by sitting inside the hook's headroom.
|
|
52
|
+
Tarball count 247 -> 250.
|
|
53
|
+
|
|
54
|
+
## 7.5.0 — the reader pair joins the known-prior refresh lane; the mirrored reader gains the structure verdict (AD-114)
|
|
55
|
+
|
|
56
|
+
Slice 2b's `spec-check` will need a structural read of a spec document, and the reader is the ONE
|
|
57
|
+
definition of well-formed — so the memory canon's reader (mirrored here byte-identically) now
|
|
58
|
+
returns an additive `structure` field. Because the bundled reader bytes move, the reader pair joins
|
|
59
|
+
the AD-113 known-prior refresh lane in the SAME release: without catalog rows for the outgoing
|
|
60
|
+
bodies, every 4.6.x deployment would classify `custom` against the new bundle and be stranded out of
|
|
61
|
+
the store seed forever.
|
|
62
|
+
|
|
63
|
+
- **`tools/script-priors.mjs` — the catalog spans both pairs.** `PRIOR_FILES` is now the FULL
|
|
64
|
+
refreshable catalog (checker pair + reader pair); `SCRIPT_PRIORS` appends the outgoing
|
|
65
|
+
4.6.0..4.6.1 reader-pair bodies (rows 4 -> 6, fixtures under `4.6.0/`, the literal frozen prefix
|
|
66
|
+
extended with its first four rows byte-identical). The maintenance rule generalizes with it: a
|
|
67
|
+
release that changes ANY catalog member appends the outgoing body's row.
|
|
68
|
+
- **`tools/ensure-specs.mjs` — the pairs decoupled, the reader refreshes.** `CHECKER_PAIR` was an
|
|
69
|
+
alias of `PRIOR_FILES`, so the widened catalog would have double-surveyed the readers: the op now
|
|
70
|
+
owns exported `READER_PAIR`/`CHECKER_PAIR` (the suite pins pairs-union === catalog domain), and
|
|
71
|
+
`decideWrites` admits a reader REFRESH — reader seeds stay unconditional create-only (a custom
|
|
72
|
+
neighbour never blocks them); a refresh runs only inside a pair whose every file is
|
|
73
|
+
current | prior | absent; one custom file withholds its pair's REFRESHES and every downstream
|
|
74
|
+
write, DIRECTIONALLY (a prior reader still refreshes beside a custom checker, which holds back
|
|
75
|
+
only its own pair and the store). Order reader -> checker -> store unchanged; lines and tokens
|
|
76
|
+
unchanged (`refreshed` now covers either pair).
|
|
77
|
+
- **Prose caught up at every live surface** — `references/modes/upgrade.md` (the configs summary
|
|
78
|
+
and the spec-layer paragraph), the `ensure-configs` header + help, the vocabulary comment, the
|
|
79
|
+
package-content ledger, and the memory twin's refresh-lane sentence all name BOTH pairs.
|
|
80
|
+
- **Proof.** The state table gains the reader-prior rows, the directional-withhold pin and dry-run
|
|
81
|
+
rows; the E2E upgrades a real 4.6.x deployment (reader on the 4.6.0 bodies, checker current)
|
|
82
|
+
whole under the real installed pre-commit hook, and the whole-chain prior+prior cell stays
|
|
83
|
+
table-covered. Red-first throughout — three red-proofs minted on frozen test bytes against the
|
|
84
|
+
pre-fix modules. Tarball count unchanged at 247.
|
|
85
|
+
|
|
7
86
|
## 7.4.0 — the spec layer reaches existing deployments: known-prior refresh, seed-behind-checker, the `specs` ensure (AD-113)
|
|
8
87
|
|
|
9
88
|
7.3.0 made a FRESH bootstrap deliver the feature-spec layer whole; this release gives every EXISTING
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '7.
|
|
6
|
+
version: '7.6.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.6.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -19,6 +19,8 @@ Run **`node ${CLAUDE_SKILL_DIR}/tools/procedures.mjs <activity> [--override <slo
|
|
|
19
19
|
|
|
20
20
|
**Declared source-size practice block (upfront knowledge, D-17 U1).** When the project declares a source-size practice in `docs/ai/source-size.json`, the advisor also renders `Declared source-size practice (docs/ai/source-size.json) — known BEFORE the code is written:` — the caps, what is recorded and the canonical WHY sentence (the plan-time layout itself is the planning canon's Module ledger). `--json` carries the SAME lines under **`declaredPractice`** (an empty array when nothing is declared), so the human and structured renders never diverge. The four config states each read as themselves: **ABSENT** renders nothing at all (a project declaring no practice is handed no invented limits), **AUTHORED** and **INCOMPLETE** are pre-mint (the second names the half-written machine key), **MINTED** carries the recorded counts. A declaration that cannot be read (malformed, unknown-keyed, a dangling symlink) renders ONE loud `UNREADABLE` line and the advisor **still exits 0** — the practice's own checker owns the exit code for its config (exit 2 there) and its declared gate reds the gate matrix. Read through the practice's pure read core, so this advisor never reaches its writer half.
|
|
21
21
|
|
|
22
|
+
**Spec-store block (the feature-spec layer, `plan-execution` only).** After a `plan-execution` render the advisor also prints `Spec store (the feature-spec layer) — …`: the two POPULATED `spec-check` commands and the session register they read. **Session lane** — `--ops-file docs/plans/spec-ops.list` (or repeated `--op <add|modify|remove>=<path>` / `rename=<old>:<new>`) judges the closure of the declared change: the targets plus each one's listing parent, on their post-state, their reader verdict, their per-kind line cap, their scenario bindings and the containment of every path they name. **Store lane** — `--all` judges the whole store instead and adds the four cross-document invariants (an unlisted child, DISTINCT from an orphan · acyclicity · store-wide slug uniqueness · module overlap); an absent store root refuses the run rather than reporting an empty clean store. The two lanes are exclusive. **Register lifecycle:** `docs/plans/spec-ops.list` is *session scratch* — the session writes it, the plan's Phase Cleanup deletes it — and it is **never defaulted** by the tool, because an unnamed register would attest a post-state nobody declared (the `fold-scope --queue` precedent). `--json` carries the same lines under **`specCheck`** (an empty array outside `plan-execution`). Advisory like the finding-scope block: nothing records that it ran. Exit `0` clean · `1` findings · `2` usage.
|
|
23
|
+
|
|
22
24
|
**Cap-soft-skip degradation (the feature's only AUTO route).** The activity procedures are auto-discoverable only through the one-line **`workflow:methodology`** pointer (this kit + the engine carry `disable-model-invocation:true`, so NL like "write a plan" does **not** auto-load this skill). On a deployment whose methodology pointer was cap-soft-skipped — or whose pre-existing customized pointer lacks the procedures clause — the procedures are still reachable by **explicitly** invoking `/agent-workflow-kit procedures`; surface that plainly rather than treating it as a gap.
|
|
23
25
|
|
|
24
26
|
**Invariants:** read-only · never writes · never commits · never runs a subscription CLI · the deterministic resolution is the kit's, the recipe execution is the orchestrator's.
|
|
@@ -30,7 +30,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
30
30
|
|
|
31
31
|
**`footprint` — hidden-mode footprint reconcile (D9 / AD-014).** A deployment does not record whether it chose `hidden`, so first **infer visibility** — the dry-run writes **zero bytes** and reports one of — **visible** (the entry point is tracked) → nothing to do; **ambiguous** (untracked but not ignored — could be a fresh uncommitted repo, or a hide that broke) → **ASK** the user which it is, never guess; **hidden** → re-run without `--dry-run` to migrate any older **machine-global** hide to the **project-local** `.git/info/exclude` (one managed block; folds in the legacy `.claude/skills/` line), idempotently (a clean re-run is zero-diff). Handle its surfaced paths exactly as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`) (already-committed → show `git rm --cached`, ask before `--include`; generic-name present file → ask; **leftover machine-wide ignore block → ASK before `--remove-global`**, default keep + report). No Node on the agent host / Windows → as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`). This runs on **every** hidden upgrade, like the methodology slot — no lineage-head bump, no migration file.
|
|
32
32
|
|
|
33
|
-
**`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all six** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts · spec layer · navigator index — in a fixed order, and prints **one outcome line per ensure**: paste those lines into the step 4 / step 8 success report. Every SEED is **create-only** (an existing file is preserved byte-for-byte); the three refresh-class ops are named apart — the orchestration onboarding note (refreshed only while it still matches a canonical this kit shipped), the spec-layer checker
|
|
33
|
+
**`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all six** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts · spec layer · navigator index — in a fixed order, and prints **one outcome line per ensure**: paste those lines into the step 4 / step 8 success report. Every SEED is **create-only** (an existing file is preserved byte-for-byte); the three refresh-class ops are named apart — the orchestration onboarding note (refreshed only while it still matches a canonical this kit shipped), the spec-layer reader and checker pairs (below) and the **navigator index**, a GENERATED artifact regenerated whenever it is missing or stale (never authored content, so there is nothing to preserve). One ensure failing **never** skips the others: each reports its own outcome and the run exits non-zero when any of them `failed`. The outcome tokens, by ensure: orchestration → `seeded` / `note-refreshed` / `already-current` / `customized-preserved` / `malformed-preserved`; gates and autonomy → `seeded` / `already-present`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node`; specs → `seeded` / `refreshed` / `already-present` / `customized-preserved` / `skipped-no-node`; index → `regenerated` / `already-current`; and any ensure may report `failed`, whose line OPENS with the cause (relay it with that cause — never soften it into a skip; an op that copies file by file also states when it stopped partway). The cause vocabulary is CLOSED — one of `race-unresolved`, `template-unreadable`, `bundle-unreadable`, `adr-layout-unverifiable`, `wrong-node-kind`, `write-refused`, `unexpected-error`, `generator-unlaunchable`, `generator-failed`, `index-probe-failed`, `index-stale-after-write` — and every cause that can only arise AFTER the generator ran (`generator-failed`, and `index-probe-failed` / `index-stale-after-write` when they follow a reported regeneration) DISCLOSES in its own line that a write may already have landed. **A non-zero exit STOPs this upgrade** — report the failed line and stop there, before the equal-head exit, the migrations and the re-stamp. Add `--dry-run` to preview without writing a byte. Like the pointer slots + the footprint reconcile, all six reach an equal-head deployment **without a lineage-head bump or a migration file** (they are `.json` / `scripts/` / a generated artifact / a seeded store root, inherently outside the docs cap-validator).
|
|
34
34
|
|
|
35
35
|
**What the orchestration-config ensure does.** `docs/ai/orchestration.json` must exist **and its onboarding note must be current**: created from the canonical seed if missing; if it already exists, **every activity/slot the user set is preserved** and ONLY the `_README` note is refreshed, and only when the existing one still matches a known prior canonical — the tested `refreshIfCanonical` / `refreshReadme` in `tools/orchestration-config.mjs` is the source of truth for that decision (it normalizes CRLF/whitespace before comparing; a *customized* `_README` is preserved verbatim → `customized-preserved`; a *malformed* existing config is **preserved untouched + LOUD** → `malformed-preserved`, never clobbered and never silently skipped). The current note points at `/agent-workflow-kit set-recipe`. **Kit-owned:** in the **delegated** path memory only seeds/preserves the file (memory upgrade step 2) and this ensure applies the `_README` refresh; in the **fallback** path it does both. (Memory stays standalone.)
|
|
36
36
|
|
|
@@ -42,7 +42,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
42
42
|
|
|
43
43
|
**What the navigator ensure does.** `docs/ai/index.md` is the always-loaded navigator the entry point declares, and it is GENERATED — no template ships it, so a deployment that never ran the generator boots from a broken entry point (and, on a Node project, carries a pre-commit hook that fails its own index check). The ensure runs the bundled generator's finalizer and reports `regenerated` (it was missing or stale — it was written) or `already-current` (nothing written). It never skips a No-Node project: the generator runs from `${CLAUDE_SKILL_DIR}/references/scripts/` on the agent host, not from the project's `scripts/`. **Its position in the run-list is EARLY and therefore NOT authoritative** — `lens` (and, on the migrated path, steps 6–7) still change `docs/ai` afterwards — so the authoritative run is the LATE `--only index` rung documented at both exits; the early one is idempotent and costs at most an `already-current` line.
|
|
44
44
|
|
|
45
|
-
**What the spec-layer ensure does.** A deployment older than the feature-spec layer lacks `scripts/spec-schema.mjs` and `docs/ai/specs/index.md`, and its deployed `check-docs-size.mjs` predates the store collapse. In ONE order: the reader pair
|
|
45
|
+
**What the spec-layer ensure does.** A deployment older than the feature-spec layer lacks `scripts/spec-schema.mjs` and `docs/ai/specs/index.md`, and its deployed `check-docs-size.mjs` predates the store collapse. In ONE order: the reader pair, then the checker pair — each seeded when absent and REFRESHED only while a file's bytes are a body a release shipped (an append-only digest catalog → `refreshed`; an edited body is preserved verbatim and withholds the writes that depend on it, the checker lane waiting on a byte-current reader pair); the store root is seeded from the bundled template, date rendered, ONLY behind a checker pair current after the run — an older or edited checker renders the store row by row and reds the hook's `--check-index`, so behind a custom checker the store root is NOT seeded and the line names the remedy (copy the pair from `${CLAUDE_SKILL_DIR}/references/scripts/` by hand, re-run). One token by precedence: `seeded` > `refreshed` > `customized-preserved` (an edited pair, and this run wrote nothing) > `already-present`; `skipped-no-node` without a `package.json`; every line states what this run did, and a write that stops partway names what landed. The legacy-ADR instruct never withholds it.
|
|
46
46
|
|
|
47
47
|
**`gates-migration` — legacy gates.json migration (consented preview — D8).** An EXISTING declaration may still carry the retired review-ledger / fold-completeness checks. Run the preview (dry-run — writes NOTHING), show the user the exact plan, and only on an explicit yes re-run it with `--apply`: canonical legacy entries (matched by their documented single-invocation cmd forms) are REMOVED, the canonical `unit-tests` cmd gains the built-in lcov reporters, and the coverage-check gate is ADDED last — atomic and COMPLETE, so the migrated declaration satisfies `run-gates --final`. **The checker rides a PRODUCER or is not declared at all** (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`): with no gate producing the lcov it reads, the migration does NOT add it, an already-declared one is reported INERT, the result is not called final-run-capable, and the preview prints the paste-ready suite cmd to declare by hand — nothing is ever removed for you. CUSTOMIZED entries are NEVER auto-touched: the preview names each with a paste-ready recovery, and the commit guard must NOT be installed until they are resolved. This is the ONLY gates.json writer at upgrade (the consented FILL preview runs at init).
|
|
48
48
|
|
|
@@ -310,13 +310,45 @@ const checkParts = (parsed, at, errors) => {
|
|
|
310
310
|
if (duplicate !== undefined) errors.push({ rule: 'parts', message: `part "${duplicate}" is listed twice` });
|
|
311
311
|
};
|
|
312
312
|
|
|
313
|
-
// The verdict
|
|
314
|
-
//
|
|
315
|
-
//
|
|
313
|
+
// The structure verdict (additive, slice 2a): the DETERMINISTIC extraction of what parsed, per
|
|
314
|
+
// section, targets VERBATIM as written (`./x.md` and `./x/index.md` stay distinct strings). The
|
|
315
|
+
// grammar is per line for scenarios/children/parts — a malformed line is simply absent — while the
|
|
316
|
+
// module is a conjunction (ONE `dir/` root or an all-file list): prose, a refused path, a mix or
|
|
317
|
+
// `*(empty)*` extracts null. `## Links` is free prose and is never extracted.
|
|
318
|
+
const scenarioEntry = (line) => {
|
|
319
|
+
const scenario = parseScenario(line);
|
|
320
|
+
if (scenario === null) return null;
|
|
321
|
+
return { ordinal: scenario.n, binding: scenario.bound ? { file: scenario.path, marker: scenario.marker } : null };
|
|
322
|
+
};
|
|
323
|
+
const linkEntry = (re) => (line) => {
|
|
324
|
+
const m = line.match(re);
|
|
325
|
+
return m && SLUG_RE.test(m[2]) ? { name: m[1], target: `./${m[2]}${m[3] ?? ''}.md` } : null;
|
|
326
|
+
};
|
|
327
|
+
const entriesOf = (parsed, heading, entryOf) =>
|
|
328
|
+
contentOf(sectionLines(parsed, heading) ?? []).map(entryOf).filter((entry) => entry !== null);
|
|
329
|
+
const extractModule = (parsed) => {
|
|
330
|
+
const content = contentOf(sectionLines(parsed, '## Module') ?? []);
|
|
331
|
+
const paths = bulletsOf(content);
|
|
332
|
+
if (paths.length === 0 || content.some((line) => !isBullet(line))) return null;
|
|
333
|
+
const kinds = paths.map(classifyPath);
|
|
334
|
+
if (paths.length === 1 && kinds[0] === 'dir') return { form: 'root', paths };
|
|
335
|
+
return kinds.every((kind) => kind === 'file') ? { form: 'fileSet', paths } : null;
|
|
336
|
+
};
|
|
337
|
+
const extractStructure = (parsed) => ({
|
|
338
|
+
scenarios: entriesOf(parsed, '## Scenarios', scenarioEntry),
|
|
339
|
+
children: entriesOf(parsed, '## Children', linkEntry(CHILD_LINK_RE)),
|
|
340
|
+
parts: entriesOf(parsed, '## Parts', linkEntry(PART_LINK_RE)),
|
|
341
|
+
module: extractModule(parsed),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// The verdict: { kind, status, revision, structure, errors: [{ rule, message }], warnings: [{ rule,
|
|
345
|
+
// message }] }. Errors are collected past the first defect wherever later checks stay meaningful; a
|
|
346
|
+
// missing or defective frontmatter and an unknown kind each end the read (structure stays null),
|
|
347
|
+
// because no shape can be judged without them.
|
|
316
348
|
export const readSpecDocument = (text, rel) => {
|
|
317
349
|
const errors = [];
|
|
318
350
|
const warnings = [];
|
|
319
|
-
const verdict = (kind, status, revision) => ({ kind, status, revision, errors, warnings });
|
|
351
|
+
const verdict = (kind, status, revision, structure = null) => ({ kind, status, revision, structure, errors, warnings });
|
|
320
352
|
const front = parseFrontmatter(text.replace(/\r\n/g, '\n'));
|
|
321
353
|
if (front === null) {
|
|
322
354
|
errors.push({ rule: 'frontmatter', message: 'missing YAML frontmatter' });
|
|
@@ -351,5 +383,5 @@ export const readSpecDocument = (text, rel) => {
|
|
|
351
383
|
}
|
|
352
384
|
const status = kind === 'spec' ? fields.status ?? null : null;
|
|
353
385
|
const revision = kind === 'spec' && REVISION_RE.test(fields.revision ?? '') ? Number(fields.revision) : null;
|
|
354
|
-
return verdict(kind, status, revision);
|
|
386
|
+
return verdict(kind, status, revision, extractStructure(parsed));
|
|
355
387
|
};
|
|
@@ -194,6 +194,73 @@ describe('readSpecDocument — refuse, exactly one rule per defect', () => {
|
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
describe('readSpecDocument — the structure verdict (additive, slice 2a)', () => {
|
|
198
|
+
const structureOf = (text, rel) => readSpecDocument(text, rel).structure;
|
|
199
|
+
const BOTH_SCENARIOS = [
|
|
200
|
+
{ ordinal: 1, binding: { file: 'test/login.test.mjs', marker: 'spec:login/S1' } },
|
|
201
|
+
{ ordinal: 2, binding: null },
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
it('a flat spec extracts scenarios (bound + unbound) and its one dir/ module root', () => {
|
|
205
|
+
expect(structureOf(specDoc(), 'login.md')).toEqual({
|
|
206
|
+
scenarios: BOTH_SCENARIOS,
|
|
207
|
+
children: [],
|
|
208
|
+
parts: [],
|
|
209
|
+
module: { form: 'root', paths: ['src/login/'] },
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('a promoted root extracts parts and a fileSet module; ## Links stays free prose, never extracted', () => {
|
|
214
|
+
const text = specDoc({ module: '- src/a.mjs\n- src/b.mjs', extra: '\n## Parts\n\n- [sessions](./sessions.md)\n\n## Links\n\n- [[AD-112]]\n' });
|
|
215
|
+
expect(structureOf(text, 'auth/login/index.md')).toEqual({
|
|
216
|
+
scenarios: BOTH_SCENARIOS,
|
|
217
|
+
children: [],
|
|
218
|
+
parts: [{ name: 'sessions', target: './sessions.md' }],
|
|
219
|
+
module: { form: 'fileSet', paths: ['src/a.mjs', 'src/b.mjs'] },
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('an index extracts children with VERBATIM targets — ./x.md and ./x/index.md stay distinct strings', () => {
|
|
224
|
+
expect(structureOf(indexDoc(), 'auth/index.md')).toEqual({
|
|
225
|
+
scenarios: [],
|
|
226
|
+
children: [{ name: 'login', target: './login.md' }, { name: 'billing', target: './billing/index.md' }],
|
|
227
|
+
parts: [],
|
|
228
|
+
module: null,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('a part extracts the empty structure; a retired *(empty)* module extracts null on a CLEAN document', () => {
|
|
233
|
+
expect(structureOf(partDoc(), 'auth/login/sessions.md')).toEqual({ scenarios: [], children: [], parts: [], module: null });
|
|
234
|
+
const retired = specDoc({ fields: { status: 'retired' }, module: '*(empty)*', scenarios: ['- S1 gone :: unbound'] });
|
|
235
|
+
expect(structureOf(retired, 'login.md').module).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('EVERY early refusal reads structure null: missing frontmatter, a frontmatter defect, an unknown or absent kind', () => {
|
|
239
|
+
expect(structureOf('# Spec: Login\n', 'login.md')).toBeNull();
|
|
240
|
+
expect(structureOf(specDoc({ fields: { priority: 'high' } }), 'login.md')).toBeNull();
|
|
241
|
+
expect(structureOf(specDoc({ fields: { kind: 'feature' } }), 'login.md')).toBeNull();
|
|
242
|
+
expect(structureOf(specDoc({ drop: ['kind'] }), 'login.md')).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('a grammar-malformed scenario/child/part line is simply ABSENT — valid lines before and after it extract', () => {
|
|
246
|
+
const s = structureOf(specDoc({ scenarios: ['- S1 a :: unbound', '- S2 broken', '- S3 c :: unbound'] }), 'login.md');
|
|
247
|
+
expect(s.scenarios).toEqual([{ ordinal: 1, binding: null }, { ordinal: 3, binding: null }]);
|
|
248
|
+
const c = structureOf(indexDoc({ children: ['- [a](./a.md)', '- broken', '- [b](./b/index.md)'] }), 'auth/index.md');
|
|
249
|
+
expect(c.children).toEqual([{ name: 'a', target: './a.md' }, { name: 'b', target: './b/index.md' }]);
|
|
250
|
+
const p = structureOf(specDoc({ extra: '\n## Parts\n\n- [a](./a.md)\n- broken\n- [b](./b.md)\n' }), 'auth/login/index.md');
|
|
251
|
+
expect(p.parts).toEqual([{ name: 'a', target: './a.md' }, { name: 'b', target: './b.md' }]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('the module is a CONJUNCTION — prose, a refused path, a dir/file mix each extract null; a rule-refused scenario line still extracts verbatim', () => {
|
|
255
|
+
for (const module of ['the root is\n- src/login/', '- ../src/', '- src/login/\n- src/login/a.mjs']) {
|
|
256
|
+
expect(structureOf(specDoc({ module }), 'login.md').module).toBeNull();
|
|
257
|
+
}
|
|
258
|
+
const v = readSpecDocument(specDoc({ scenarios: ['- S1 a :: test/a.mjs :: spec:login/S2'] }), 'login.md');
|
|
259
|
+
expect(v.errors.map((e) => e.rule)).toEqual(['scenario-marker']);
|
|
260
|
+
expect(v.structure.scenarios).toEqual([{ ordinal: 1, binding: { file: 'test/a.mjs', marker: 'spec:login/S2' } }]);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
197
264
|
describe('classifyPath + the frozen constants', () => {
|
|
198
265
|
it('classifies the lexical path forms', () => {
|
|
199
266
|
expect(['src/', 'src/a.mjs', '../x', '/x', 'C:/x', 'a\\b', 'src/*.mjs'].map(classifyPath)).toEqual([
|
package/tools/ensure-configs.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// gates docs/ai/gates.json seed-if-missing (an existing declaration is authored content)
|
|
6
6
|
// autonomy docs/ai/autonomy.json seed-if-missing (same)
|
|
7
7
|
// scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
|
|
8
|
-
// specs scripts/<spec layer> reader
|
|
8
|
+
// specs scripts/<spec layer> reader + checker pairs seeded when absent, refreshed only on
|
|
9
9
|
// + docs/ai/specs/index.md a shipped prior body, store root seeded behind a current checker
|
|
10
10
|
// index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact)
|
|
11
11
|
//
|
|
@@ -64,9 +64,10 @@ Usage:
|
|
|
64
64
|
|
|
65
65
|
Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
|
|
66
66
|
refreshed in place. Three ops refresh instead: the orchestration onboarding note, only while it still
|
|
67
|
-
matches a canonical the kit shipped (your own wording is preserved verbatim); the spec-layer
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
matches a canonical the kit shipped (your own wording is preserved verbatim); the spec-layer reader
|
|
68
|
+
and checker pairs, only while a file's bytes are a body a release shipped (an edited file is
|
|
69
|
+
preserved, and the writes that depend on it — the spec store root included — are withheld behind
|
|
70
|
+
it); and the navigator index — a GENERATED artifact, regenerated
|
|
70
71
|
whenever it is missing or stale. The enforcement-script ensure detects an older ADR-store layout
|
|
71
72
|
FIRST and instructs the opt-in migration instead of seeding.
|
|
72
73
|
|
package/tools/ensure-specs.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// ensure-specs.mjs — the SIXTH upgrade ensure: the spec layer (memory 4.6.0, AD-112) delivered to an
|
|
2
2
|
// EXISTING deployment on an equal-head upgrade. Four deployed scripts and one store root, written in
|
|
3
3
|
// a FIXED order the measured coupling dictates:
|
|
4
|
-
// 1. the reader pair scripts/spec-schema.mjs + .test.mjs —
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// 1. the reader pair scripts/spec-schema.mjs + .test.mjs — created when absent; since 2a
|
|
5
|
+
// also REFRESHED when the deployed bytes are a body a release shipped (script-priors.mjs), a
|
|
6
|
+
// custom body preserved verbatim (brief D1 shipped it create-only; the 4.6.x bodies joining the
|
|
7
|
+
// catalog is what keeps those deployments out of the stranded-custom cell);
|
|
8
|
+
// 2. the checker pair scripts/check-docs-size.mjs + .test.mjs — the same prior-refresh lane, and
|
|
9
|
+
// only behind a reader pair this run leaves byte-current;
|
|
9
10
|
// 3. the store root docs/ai/specs/index.md — seeded (placeholders rendered)
|
|
10
11
|
// only once both pairs are current.
|
|
11
12
|
// Why the order: the kit's bundled navigator generator collapses `specs/` into one row, while the
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
25
26
|
import { join } from 'node:path';
|
|
26
27
|
import { writeContainedFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
|
|
27
|
-
import {
|
|
28
|
+
import { classifyDeployedScript } from './script-priors.mjs';
|
|
28
29
|
import { composeFailure, composeOutcome, isNodeProject, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
|
|
29
30
|
|
|
30
31
|
const OP = 'specs';
|
|
@@ -33,8 +34,10 @@ const BUNDLED_SCRIPTS = ['references', 'scripts'];
|
|
|
33
34
|
const STORE_ROOT_REL = 'docs/ai/specs/index.md';
|
|
34
35
|
const STORE_ROOT_TEMPLATE = ['references', 'templates', 'specs', 'index.md'];
|
|
35
36
|
const DATE_PLACEHOLDER = '{{DATE}}';
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
// Explicit pairs, deliberately NOT aliased to the catalog's PRIOR_FILES (which spans both): each
|
|
38
|
+
// file is surveyed exactly once. Exported so the suite pins pairs-union === catalog domain.
|
|
39
|
+
export const READER_PAIR = Object.freeze(['spec-schema.mjs', 'spec-schema.test.mjs']);
|
|
40
|
+
export const CHECKER_PAIR = Object.freeze(['check-docs-size.mjs', 'check-docs-size.test.mjs']);
|
|
38
41
|
|
|
39
42
|
const ok = (token, lines) => composeOutcome(OP, token, lines, false);
|
|
40
43
|
const loud = (cause, ...lines) => composeFailure(OP, cause, ...lines);
|
|
@@ -67,14 +70,20 @@ const surveyStoreRoot = (cwd, lstat) => {
|
|
|
67
70
|
|
|
68
71
|
// ── the decision: which writes the survey admits (pure over the survey) ───────────────────────────
|
|
69
72
|
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
// Reader seeds are create-only and always admitted. A REFRESH overwrites bytes, so it runs only
|
|
74
|
+
// inside a pair whose every file is proven refreshable (current | prior | absent) — one custom file
|
|
75
|
+
// withholds its whole pair; the checker lane (its seeds included) and the store additionally wait
|
|
76
|
+
// for a reader pair this run leaves byte-current (seeded + refreshed = current).
|
|
77
|
+
const PAIR_ELIGIBLE = new Set(['current', 'prior', 'absent']);
|
|
72
78
|
|
|
73
79
|
export const decideWrites = ({ reader, checker, store }) => {
|
|
74
|
-
const
|
|
75
|
-
const checkerEligible =
|
|
80
|
+
const readerEligible = reader.every((f) => PAIR_ELIGIBLE.has(f.state));
|
|
81
|
+
const checkerEligible = readerEligible && checker.every((f) => PAIR_ELIGIBLE.has(f.state));
|
|
76
82
|
const writes = [];
|
|
77
|
-
for (const f of reader)
|
|
83
|
+
for (const f of reader) {
|
|
84
|
+
if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
|
|
85
|
+
if (readerEligible && f.state === 'prior') writes.push({ kind: 'refresh', file: f });
|
|
86
|
+
}
|
|
78
87
|
if (checkerEligible) {
|
|
79
88
|
for (const f of checker) {
|
|
80
89
|
if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
|
|
@@ -14,8 +14,8 @@ export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', '
|
|
|
14
14
|
|
|
15
15
|
// Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
|
|
16
16
|
// walks this set), and each has exactly one `would-` counterpart below. `refreshed` is the spec-layer
|
|
17
|
-
// ensure's
|
|
18
|
-
// bundled one (a custom body is never refreshed).
|
|
17
|
+
// ensure's pair refresh (reader or checker) — a deployed script on a body a release shipped,
|
|
18
|
+
// rewritten to the bundled one (a custom body is never refreshed).
|
|
19
19
|
export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated']);
|
|
20
20
|
export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate']);
|
|
21
21
|
|
package/tools/procedures.mjs
CHANGED
|
@@ -319,6 +319,22 @@ const foldScopeAdvice = (activity, config, plans) => {
|
|
|
319
319
|
];
|
|
320
320
|
};
|
|
321
321
|
|
|
322
|
+
// The spec-store block (the feature-spec layer) — plan-execution ONLY and unconditional, like the
|
|
323
|
+
// finding-scope block above: a change to docs/ai/specs/ is judged whoever reviews it. It carries
|
|
324
|
+
// only what no canon can: the POPULATED checker commands, and the session register they read. The
|
|
325
|
+
// register is NEVER defaulted by the tool — naming it here is the whole point of the block.
|
|
326
|
+
export const SPEC_CHECK_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'spec-check-cli.mjs');
|
|
327
|
+
const SPEC_OPS_REGISTER = `${PLANS_REL}/spec-ops.list`;
|
|
328
|
+
const specCheckAdvice = (activity) => {
|
|
329
|
+
if (activity !== 'plan-execution') return [];
|
|
330
|
+
return [
|
|
331
|
+
'Spec store (the feature-spec layer) — state what this session changed, then let the checker judge the store against it:',
|
|
332
|
+
` • node ${shellQuoteArg(SPEC_CHECK_TOOL)} --ops-file ${shellQuoteArg(SPEC_OPS_REGISTER)} (or --op '<add|modify|remove>=docs/ai/specs/<slug>.md', repeatable; rename=<old>:<new>)`,
|
|
333
|
+
` • node ${shellQuoteArg(SPEC_CHECK_TOOL)} --all — the whole store instead: unlisted child vs orphan, acyclicity, store-wide slug uniqueness, module overlap.`,
|
|
334
|
+
` • ${SPEC_OPS_REGISTER} is SESSION SCRATCH: this session writes it, the plan's Cleanup deletes it. It is never defaulted — an unnamed register would attest a post-state nobody declared. Advisory: nothing records that it ran.`,
|
|
335
|
+
];
|
|
336
|
+
};
|
|
337
|
+
|
|
322
338
|
// The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
|
|
323
339
|
// at the point of use like reviewLoopAdvice paraphrases procedures.md Fold + loop / orchestration §4). Rendered UNCONDITIONALLY for
|
|
324
340
|
// every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
|
|
@@ -466,7 +482,7 @@ const contractLines = ({ cmd, contract, settings }) => {
|
|
|
466
482
|
return lines;
|
|
467
483
|
};
|
|
468
484
|
|
|
469
|
-
const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => {
|
|
485
|
+
const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck }) => {
|
|
470
486
|
const lines = [
|
|
471
487
|
section,
|
|
472
488
|
'',
|
|
@@ -486,6 +502,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
|
|
|
486
502
|
const advice = reviewLoopAdvice(slots, activity);
|
|
487
503
|
if (advice.length) lines.push('', ...advice);
|
|
488
504
|
if (foldScope.length) lines.push('', ...foldScope);
|
|
505
|
+
if (specCheck.length) lines.push('', ...specCheck);
|
|
489
506
|
lines.push('', ...costLanesAdvice());
|
|
490
507
|
if (declaredPractice.length) lines.push('', ...declaredPractice);
|
|
491
508
|
if (warnings.length) {
|
|
@@ -495,7 +512,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
|
|
|
495
512
|
return lines.join('\n');
|
|
496
513
|
};
|
|
497
514
|
|
|
498
|
-
const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => ({
|
|
515
|
+
const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck }) => ({
|
|
499
516
|
activity,
|
|
500
517
|
section,
|
|
501
518
|
slots: Object.fromEntries(
|
|
@@ -510,6 +527,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
|
|
|
510
527
|
costLanes: costLanesAdvice(),
|
|
511
528
|
// ADDITIVE (the fold channel): the finding-scope block, structured (empty outside plan-execution).
|
|
512
529
|
foldScope,
|
|
530
|
+
// ADDITIVE (spec layer 2b): the spec-store block, structured (empty outside plan-execution).
|
|
531
|
+
specCheck,
|
|
513
532
|
// ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
|
|
514
533
|
autonomy: autonomyAdvice(activity, autonomy),
|
|
515
534
|
// ADDITIVE (D-17 U1): the SAME composed lines the human render prints — one array, two renders, so
|
|
@@ -599,9 +618,10 @@ export const main = (argv, ctx = {}) => {
|
|
|
599
618
|
const flowHalves = config?.flow == null ? null : flowHalvesAdvice(config.flow, flowProbe(cwd));
|
|
600
619
|
const declaredPractice = declaredPracticeAdvice(cwd, readFile, lstat);
|
|
601
620
|
const foldScope = foldScopeAdvice(activity, config, plans);
|
|
621
|
+
const specCheck = specCheckAdvice(activity);
|
|
602
622
|
const stdout = json
|
|
603
|
-
? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }), null, 2)
|
|
604
|
-
: formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope });
|
|
623
|
+
? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck }), null, 2)
|
|
624
|
+
: formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck });
|
|
605
625
|
if (autonomy?.error) {
|
|
606
626
|
return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
|
|
607
627
|
}
|
package/tools/script-priors.mjs
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
|
|
16
|
-
// The deployed scripts the spec-layer ensure may REFRESH
|
|
17
|
-
//
|
|
18
|
-
|
|
16
|
+
// The deployed scripts the spec-layer ensure may REFRESH — the FULL refreshable catalog: the checker
|
|
17
|
+
// pair that imports the reader, and (since 2a) the reader pair itself. Every other deployed script is
|
|
18
|
+
// outside the refresh lane by design.
|
|
19
|
+
export const PRIOR_FILES = Object.freeze([
|
|
20
|
+
'check-docs-size.mjs', 'check-docs-size.test.mjs', 'spec-schema.mjs', 'spec-schema.test.mjs',
|
|
21
|
+
]);
|
|
19
22
|
|
|
20
23
|
const prior = (file, firstShipped, lastShipped, digest) => Object.freeze({ file, firstShipped, lastShipped, digest });
|
|
21
24
|
|
|
@@ -26,6 +29,8 @@ export const SCRIPT_PRIORS = Object.freeze([
|
|
|
26
29
|
prior('check-docs-size.mjs', '4.4.0', '4.5.0', '7a5cd7f98571c3248d0378623172e9c60073b8d8761bce7a95c263f99bfb3a42'),
|
|
27
30
|
prior('check-docs-size.mjs', '4.5.1', '4.5.4', 'fef3555b14a5ade46071bac18bd6dfc87daec39dd63ce1f7965864c3e51558d9'),
|
|
28
31
|
prior('check-docs-size.test.mjs', '4.0.0', '4.5.4', '88fbb3d7f097d74771b7c5d9ad99fcd58b274ae33f391e1ff01f4b138b9236cd'),
|
|
32
|
+
prior('spec-schema.mjs', '4.6.0', '4.6.1', 'f8ee23d81e90fd4225ca4ece288cba41982c4430290bc6d033f5ca18d2d283f4'),
|
|
33
|
+
prior('spec-schema.test.mjs', '4.6.0', '4.6.1', 'a12d6d3f5d32c6dabdee7e15af7d2ab15a0ced37515d1844fe0951f60cddbc99'),
|
|
29
34
|
]);
|
|
30
35
|
|
|
31
36
|
export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The CLI half of the structural checker: argv and fs, no rule (the rules are spec-check.mjs and
|
|
3
|
+
// spec-check-ops.mjs). The change source is EXPLICIT and never git — a session states what it did.
|
|
4
|
+
//
|
|
5
|
+
// node spec-check-cli.mjs --op modify=docs/ai/specs/login.md [--op ...] [--root <dir>]
|
|
6
|
+
// node spec-check-cli.mjs --ops-file docs/plans/spec-ops.list
|
|
7
|
+
// node spec-check-cli.mjs --all
|
|
8
|
+
//
|
|
9
|
+
// `--op` and `--ops-file` UNION and dedup by identity; `--all` is exclusive of both. The register
|
|
10
|
+
// `--ops-file` names is session scratch, and it is never defaulted: guessing which file states the
|
|
11
|
+
// change set would attest a post-state nobody declared (the fold-scope --queue precedent). A named
|
|
12
|
+
// register that does not exist is usage, not an empty change set.
|
|
13
|
+
//
|
|
14
|
+
// The leaf read is the family's descriptor-bound no-follow door (fs-read-nofollow.mjs) — a pathname
|
|
15
|
+
// swapped after the probe cannot change the bytes judged. Read-only, records nothing: ADVISORY, so
|
|
16
|
+
// a skipped or late call is indistinguishable from one that ran.
|
|
17
|
+
//
|
|
18
|
+
// Exit codes: 0 ACCEPT; 1 one or more findings; 2 usage — a missing/unknown flag, a bad op, an
|
|
19
|
+
// unreadable register, a --root that is not a directory, or an --all run with no store root.
|
|
20
|
+
// Dependency-free, Node >= 22.
|
|
21
|
+
|
|
22
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
23
|
+
import { resolve } from 'node:path';
|
|
24
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
25
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
26
|
+
import { SPEC_OPS_GRAMMAR, parseSpecOps } from './spec-check-ops.mjs';
|
|
27
|
+
import { checkSpecs } from './spec-check.mjs';
|
|
28
|
+
|
|
29
|
+
const { verbs, separator, storePrefix } = SPEC_OPS_GRAMMAR;
|
|
30
|
+
|
|
31
|
+
const HELP = `spec-check — judge the feature-spec store against what this session says it changed.
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
node spec-check-cli.mjs [--root <dir>] --op <op> [--op <op> ...]
|
|
35
|
+
node spec-check-cli.mjs [--root <dir>] --ops-file <file>
|
|
36
|
+
node spec-check-cli.mjs [--root <dir>] --all
|
|
37
|
+
|
|
38
|
+
An op is one of ${verbs.slice(0, -1).map((v) => `${v}=<path>`).join(' | ')} | rename=<old>${separator}<new>, and every
|
|
39
|
+
path is a POSIX repo-relative .md document inside ${storePrefix} whose segments are kebab slugs.
|
|
40
|
+
Nothing is normalized away and the store root is never an op target, so one document has exactly
|
|
41
|
+
one accepted spelling.
|
|
42
|
+
|
|
43
|
+
--op repeatable; unions --ops-file and dedups by identity.
|
|
44
|
+
--ops-file one op per line; blank lines and # comments parse away. Never defaulted — name the
|
|
45
|
+
register this session wrote (e.g. docs/plans/spec-ops.list).
|
|
46
|
+
--all judge the WHOLE store instead: reachability (unlisted child vs orphan), acyclicity,
|
|
47
|
+
store-wide slug uniqueness and module overlap. Exclusive of both op sources.
|
|
48
|
+
--root the repo root the paths are relative to (default: the process cwd).
|
|
49
|
+
|
|
50
|
+
The check is advisory — nothing records that it ran, so a skipped or late call is indistinguishable
|
|
51
|
+
from one made before the edit.
|
|
52
|
+
|
|
53
|
+
Exit codes: 0 ACCEPT; 1 findings; 2 usage (bad flag or op, unreadable register, bad --root).`;
|
|
54
|
+
|
|
55
|
+
const FLAGS = ['op', 'ops-file', 'all', 'root'];
|
|
56
|
+
const REPEATABLE = ['op'];
|
|
57
|
+
const BOOLEAN = ['all'];
|
|
58
|
+
|
|
59
|
+
const parseArgs = (argv) => {
|
|
60
|
+
const opts = { op: [] };
|
|
61
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
62
|
+
const arg = argv[i];
|
|
63
|
+
const eq = arg.indexOf('=');
|
|
64
|
+
const name = arg.startsWith('--') ? arg.slice(2, eq === -1 ? undefined : eq) : null;
|
|
65
|
+
if (!name || !FLAGS.includes(name)) throw new Error(`unexpected argument "${arg}" (flags: ${FLAGS.map((f) => `--${f}`).join(', ')})`);
|
|
66
|
+
if (BOOLEAN.includes(name)) {
|
|
67
|
+
if (eq !== -1) throw new Error(`--${name} takes no value`);
|
|
68
|
+
opts[name] = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
let value = eq === -1 ? argv[i + 1] : arg.slice(eq + 1);
|
|
72
|
+
if (eq === -1) i += 1;
|
|
73
|
+
if (value === undefined || (eq === -1 && value.startsWith('--'))) throw new Error(`--${name} requires a value`);
|
|
74
|
+
if (REPEATABLE.includes(name)) opts[name].push(value);
|
|
75
|
+
else opts[name] = value;
|
|
76
|
+
}
|
|
77
|
+
return opts;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// The probe: a fail-closed lstat CLASSIFICATION, never a boolean. A symlink is its own state (it is
|
|
81
|
+
// never followed to decide what sits at a path), and anything that cannot be stat-ed at all is
|
|
82
|
+
// "unreadable" rather than "absent" — the two lead to different verdicts and must not collapse.
|
|
83
|
+
export const probe = (path) => {
|
|
84
|
+
try {
|
|
85
|
+
const st = lstatSync(path);
|
|
86
|
+
return st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'unreadable';
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return err && err.code === 'ENOENT' ? 'absent' : 'unreadable';
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const realpath = (path) => {
|
|
93
|
+
try {
|
|
94
|
+
return realpathSync(path);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const list = (path) => {
|
|
101
|
+
try {
|
|
102
|
+
return readdirSync(path);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// A LINE ENDING is not content: the split takes CRLF as well as LF, so a register saved on Windows
|
|
109
|
+
// reads like one saved anywhere else. Blank and `#` lines parse away; every OTHER line is handed on
|
|
110
|
+
// UNTRIMMED, because trimming here would let the file lane accept a spelling the --op lane refuses —
|
|
111
|
+
// exactly the alias the frozen grammar denies. The ops parser refuses that whitespace out loud.
|
|
112
|
+
const opsFileLines = (text) => text.split(/\r?\n/).filter((line) => line.trim() !== '' && !line.trim().startsWith('#'));
|
|
113
|
+
|
|
114
|
+
// main(argv, deps) -> { code, stdout, stderr }. Never calls process.exit itself (the direct-run
|
|
115
|
+
// guard does), and never reads anything the caller did not point it at.
|
|
116
|
+
export const main = (argv, deps = {}) => {
|
|
117
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
118
|
+
const read = deps.readFileSync ?? readFileSync;
|
|
119
|
+
const cwd = deps.cwd ?? (() => process.cwd());
|
|
120
|
+
const usage = (message) => ({ code: 2, stdout: '', stderr: `spec-check: ${message}` });
|
|
121
|
+
let opts;
|
|
122
|
+
try {
|
|
123
|
+
opts = parseArgs(argv);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return usage(err.message);
|
|
126
|
+
}
|
|
127
|
+
if (opts.all && (opts.op.length > 0 || opts['ops-file'])) {
|
|
128
|
+
return usage('--all judges the whole store and is exclusive of --op and --ops-file — run one lane or the other');
|
|
129
|
+
}
|
|
130
|
+
const root = resolve(cwd(), opts.root ?? '.');
|
|
131
|
+
if (probe(root) !== 'dir') return usage(`--root "${opts.root ?? cwd()}" is not a directory`);
|
|
132
|
+
let specs = [...opts.op];
|
|
133
|
+
if (opts['ops-file']) {
|
|
134
|
+
const register = resolve(cwd(), opts['ops-file']);
|
|
135
|
+
try {
|
|
136
|
+
specs = [...specs, ...opsFileLines(read(register, 'utf8'))];
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return usage(`--ops-file "${opts['ops-file']}" is unreadable — ${(err && err.message) || err}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
let ops = [];
|
|
142
|
+
if (!opts.all) {
|
|
143
|
+
const parsed = parseSpecOps(specs);
|
|
144
|
+
if (parsed.errors.length > 0) {
|
|
145
|
+
return usage(parsed.errors.map((e) => `${e.code} — ${e.message}`).join('\nspec-check: '));
|
|
146
|
+
}
|
|
147
|
+
ops = parsed.ops;
|
|
148
|
+
}
|
|
149
|
+
const decided = checkSpecs({ root, ops, all: Boolean(opts.all) }, { read: readRegularFileNoFollow, probe, realpath, list });
|
|
150
|
+
return { code: decided.exit, stdout: decided.lines.join('\n'), stderr: '' };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (isDirectRun(import.meta.url)) {
|
|
154
|
+
const result = main(process.argv.slice(2));
|
|
155
|
+
if (result.stdout) console.log(result.stdout);
|
|
156
|
+
if (result.stderr) console.error(result.stderr);
|
|
157
|
+
process.exit(result.code);
|
|
158
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// spec-check-ops.mjs — the frozen CHANGE-OP grammar of the structural checker (spec layer 2b).
|
|
2
|
+
//
|
|
3
|
+
// The change source is EXPLICIT and never git: a session states what it did to the spec store, and
|
|
4
|
+
// this module decides whether that statement is even sayable. Four verbs, one separator, one
|
|
5
|
+
// spelling per path:
|
|
6
|
+
//
|
|
7
|
+
// add=<p> | modify=<p> | remove=<p> | rename=<old>:<new>
|
|
8
|
+
//
|
|
9
|
+
// A target is a POSIX repo-relative `.md` path inside docs/ai/specs/ whose every segment is
|
|
10
|
+
// D-schema-shaped (kebab dirs; a `<slug>.md` or `index.md` leaf). Because no accepted target can
|
|
11
|
+
// carry a `:`, the rename separator is unambiguous — which is why the grammar can stay one line.
|
|
12
|
+
//
|
|
13
|
+
// NOTHING is normalized away: `./x.md`, `a//b.md` and `a/../b.md` REFUSE rather than resolving to
|
|
14
|
+
// some other path, so one document has exactly ONE accepted spelling and a dedup can be an equality.
|
|
15
|
+
// The store root is never an op target — it is the navigator, not a contract.
|
|
16
|
+
//
|
|
17
|
+
// The D-schema values come from the ONE reader (references/scripts/spec-schema.mjs): no second
|
|
18
|
+
// source of the prefix, the navigator filename or the slug pattern. Pure strings in, ops out — the
|
|
19
|
+
// filesystem belongs to spec-check.mjs. Dependency-free, Node >= 22.
|
|
20
|
+
|
|
21
|
+
import { SPEC_SCHEMA } from '../references/scripts/spec-schema.mjs';
|
|
22
|
+
|
|
23
|
+
const STORE = SPEC_SCHEMA.storePrefix;
|
|
24
|
+
const NAV = SPEC_SCHEMA.navigatorFile;
|
|
25
|
+
const SUFFIX = '.md';
|
|
26
|
+
const SEPARATOR = ':';
|
|
27
|
+
|
|
28
|
+
export const SPEC_OPS_GRAMMAR = Object.freeze({
|
|
29
|
+
verbs: Object.freeze(['add', 'modify', 'remove', 'rename']),
|
|
30
|
+
separator: SEPARATOR,
|
|
31
|
+
storePrefix: STORE,
|
|
32
|
+
navigator: NAV,
|
|
33
|
+
suffix: SUFFIX,
|
|
34
|
+
slugPattern: SPEC_SCHEMA.slugPattern,
|
|
35
|
+
storeRoot: `${STORE}${NAV}`,
|
|
36
|
+
roles: Object.freeze(['add', 'modify', 'remove', 'rename-from', 'rename-to', 'listing-parent']),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const SLUG_RE = new RegExp(SPEC_SCHEMA.slugPattern);
|
|
40
|
+
const DRIVE_RE = /^[A-Za-z]:/;
|
|
41
|
+
const VERBS = SPEC_OPS_GRAMMAR.verbs;
|
|
42
|
+
|
|
43
|
+
// The ONE target judgement, ordered from the most literal defect to the most structural. The store
|
|
44
|
+
// PREFIX is asked before the dot segments on purpose: `./docs/ai/specs/x.md` is not a store path
|
|
45
|
+
// that needs normalizing, it is a path outside the store, and saying so is the honest refusal.
|
|
46
|
+
const targetDefect = (target) => {
|
|
47
|
+
if (target.includes('\\')) return { code: 'op-target', reason: 'a backslash is not a path separator here' };
|
|
48
|
+
if (DRIVE_RE.test(target)) return { code: 'op-target', reason: 'a drive letter is not a repo-relative path' };
|
|
49
|
+
if (target.startsWith('/')) return { code: 'op-target', reason: 'an absolute path is not repo-relative' };
|
|
50
|
+
if (target.includes('//')) return { code: 'op-target', reason: 'a doubled slash is not a path segment' };
|
|
51
|
+
if (target.endsWith('/')) return { code: 'op-target', reason: 'a directory is never an op target — name the document' };
|
|
52
|
+
if (!target.startsWith(STORE)) return { code: 'op-target', reason: `the target is outside the store ${STORE}` };
|
|
53
|
+
if (target.split('/').some((segment) => segment === '.' || segment === '..')) {
|
|
54
|
+
return { code: 'op-target', reason: 'a dot segment is never resolved away — write the path as it is' };
|
|
55
|
+
}
|
|
56
|
+
if (!target.endsWith(SUFFIX)) return { code: 'op-target', reason: `a spec document is a ${SUFFIX} file` };
|
|
57
|
+
if (target === SPEC_OPS_GRAMMAR.storeRoot) {
|
|
58
|
+
return { code: 'op-root', reason: 'the store root is the navigator, never an op target' };
|
|
59
|
+
}
|
|
60
|
+
const segments = target.slice(STORE.length).split('/');
|
|
61
|
+
const leaf = segments[segments.length - 1];
|
|
62
|
+
const badDir = segments.slice(0, -1).find((segment) => !SLUG_RE.test(segment));
|
|
63
|
+
if (badDir !== undefined) return { code: 'op-segment', reason: `"${badDir}" is not a slug (${SPEC_SCHEMA.slugPattern})` };
|
|
64
|
+
const stem = leaf.slice(0, -SUFFIX.length);
|
|
65
|
+
if (leaf !== NAV && !SLUG_RE.test(stem)) return { code: 'op-segment', reason: `"${stem}" is not a slug (${SPEC_SCHEMA.slugPattern})` };
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// The slug a document owns — the folder name for an index.md, the file stem otherwise. The same
|
|
70
|
+
// rule the reader applies to `rel`, kept here because the checker asks it of WHOLE PATHS.
|
|
71
|
+
export const slugOf = (path) => {
|
|
72
|
+
const segments = path.slice(STORE.length).split('/');
|
|
73
|
+
const leaf = segments[segments.length - 1];
|
|
74
|
+
return leaf === NAV ? segments[segments.length - 2] ?? null : leaf.slice(0, -SUFFIX.length);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// The document that LISTS this one: `<dir>/index.md` for a leaf, ONE level up for an index.md
|
|
78
|
+
// (a promoted root is listed by its parent, never by itself). The store root is listed by nothing.
|
|
79
|
+
export const listingParentOf = (path) => {
|
|
80
|
+
const segments = path.slice(STORE.length).split('/');
|
|
81
|
+
const dirs = segments.slice(0, -1);
|
|
82
|
+
if (segments[segments.length - 1] === NAV) {
|
|
83
|
+
return dirs.length === 0 ? null : `${STORE}${[...dirs.slice(0, -1), NAV].join('/')}`;
|
|
84
|
+
}
|
|
85
|
+
return `${STORE}${[...dirs, NAV].join('/')}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// The shape a path plays. A path in TWO roles is refused rather than reconciled: "added and then
|
|
89
|
+
// removed" states two different post-states for one probe, and guessing which one the session meant
|
|
90
|
+
// is exactly the unresolved reference this module exists to refuse.
|
|
91
|
+
const SHAPES = Object.freeze({ 'rename-from,rename-from': 'fan-out', 'rename-to,rename-to': 'fan-in', 'rename-from,rename-to': 'chain' });
|
|
92
|
+
const roleConflict = (roles) => SHAPES[[...roles].sort().join(',')] ?? 'two roles';
|
|
93
|
+
|
|
94
|
+
// parseSpecOps(specs) -> { ops, errors }. Every op is judged, so one call names EVERY defect; any
|
|
95
|
+
// error at all empties `ops` — a partially-understood change set would attest a post-state nobody
|
|
96
|
+
// declared.
|
|
97
|
+
export const parseSpecOps = (specs) => {
|
|
98
|
+
const errors = [];
|
|
99
|
+
const ops = [];
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const roles = new Map();
|
|
102
|
+
const claim = (path, role, spec) => {
|
|
103
|
+
const held = roles.get(path);
|
|
104
|
+
if (held === undefined) roles.set(path, { role, spec });
|
|
105
|
+
else errors.push({ code: 'op-role', message: `"${spec}" and "${held.spec}" put ${path} in two roles (${roleConflict([held.role, role])})` });
|
|
106
|
+
};
|
|
107
|
+
for (const raw of specs ?? []) {
|
|
108
|
+
const spec = String(raw);
|
|
109
|
+
// Surrounding whitespace is REFUSED rather than trimmed: trimming would give one document a
|
|
110
|
+
// second accepted spelling, and the whole grammar rests on there being exactly one.
|
|
111
|
+
if (spec !== spec.trim()) {
|
|
112
|
+
errors.push({ code: 'op-grammar', message: `"${spec}" — leading or trailing whitespace is never trimmed away; write the op without it` });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const eq = spec.indexOf('=');
|
|
116
|
+
const verb = eq === -1 ? spec : spec.slice(0, eq);
|
|
117
|
+
const payload = eq === -1 ? '' : spec.slice(eq + 1);
|
|
118
|
+
if (!VERBS.includes(verb)) {
|
|
119
|
+
errors.push({ code: 'op-grammar', message: `"${spec}" — an op is verb=<target>, the verb one of ${VERBS.join('|')}` });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (payload === '') {
|
|
123
|
+
errors.push({ code: 'op-grammar', message: `"${spec}" — an op is verb=<target>, and the target is never empty` });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (seen.has(spec)) continue;
|
|
127
|
+
seen.add(spec);
|
|
128
|
+
const sides = verb === 'rename' ? payload.split(SEPARATOR) : [payload];
|
|
129
|
+
if (verb === 'rename' && sides.length !== 2) {
|
|
130
|
+
errors.push({ code: 'op-grammar', message: `"${spec}" — rename takes <old>${SEPARATOR}<new>: exactly one separator "${SEPARATOR}"` });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const defect = sides.map(targetDefect).find(Boolean);
|
|
134
|
+
if (defect) {
|
|
135
|
+
errors.push({ code: defect.code, message: `"${spec}" — ${defect.reason}` });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (verb === 'rename' && sides[0] === sides[1]) {
|
|
139
|
+
errors.push({ code: 'op-role', message: `"${spec}" — a rename to itself (self) declares no change` });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (verb === 'rename') {
|
|
143
|
+
claim(sides[0], 'rename-from', spec);
|
|
144
|
+
claim(sides[1], 'rename-to', spec);
|
|
145
|
+
ops.push({ verb, from: sides[0], to: sides[1] });
|
|
146
|
+
} else {
|
|
147
|
+
claim(payload, verb, spec);
|
|
148
|
+
ops.push({ verb, target: payload });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (errors.length === 0 && ops.length === 0) {
|
|
152
|
+
errors.push({ code: 'op-empty', message: 'no op to judge — name at least one --op or --ops-file entry, or run --all' });
|
|
153
|
+
}
|
|
154
|
+
return { ops: errors.length > 0 ? [] : ops, errors };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// buildClosure(ops) -> [{ path, roles }] sorted: every op path PLUS the document that lists it.
|
|
158
|
+
// A listing parent that is itself an op target keeps its op role; two ops under one parent name it
|
|
159
|
+
// once; ops under different parents name both.
|
|
160
|
+
export const buildClosure = (ops) => {
|
|
161
|
+
const entries = new Map();
|
|
162
|
+
const put = (path, role) => entries.set(path, [...new Set([...(entries.get(path) ?? []), role])]);
|
|
163
|
+
const targets = [];
|
|
164
|
+
for (const op of ops) {
|
|
165
|
+
if (op.verb === 'rename') {
|
|
166
|
+
put(op.from, 'rename-from');
|
|
167
|
+
put(op.to, 'rename-to');
|
|
168
|
+
targets.push(op.from, op.to);
|
|
169
|
+
} else {
|
|
170
|
+
put(op.target, op.verb);
|
|
171
|
+
targets.push(op.target);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const target of targets) {
|
|
175
|
+
const parent = listingParentOf(target);
|
|
176
|
+
if (parent !== null && !entries.has(parent)) put(parent, 'listing-parent');
|
|
177
|
+
}
|
|
178
|
+
return [...entries]
|
|
179
|
+
.map(([path, roles]) => ({ path, roles }))
|
|
180
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
181
|
+
};
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// spec-check.mjs — the structural checker of the feature-spec layer (spec layer 2b), ADVISORY.
|
|
2
|
+
//
|
|
3
|
+
// It answers ONE question per document: given what the session says it changed, does the store on
|
|
4
|
+
// disk say the same thing? The change source is EXPLICIT (parsed ops, never git), and "well-formed"
|
|
5
|
+
// has no second definition here — every per-document verdict is RELAYED from the ONE reader
|
|
6
|
+
// (references/scripts/spec-schema.mjs) and its 2a `structure` extraction. This module adds only what
|
|
7
|
+
// text alone cannot decide: does the post-state on disk match the declared op, is the document
|
|
8
|
+
// listed by its parent, does its scenario marker really occur once in the file it names, and — under
|
|
9
|
+
// --all — the five invariants that span documents.
|
|
10
|
+
//
|
|
11
|
+
// Two lanes:
|
|
12
|
+
// session the closure of the declared ops (targets + their listing parents), judged per document.
|
|
13
|
+
// --all every document under the store, plus: unlisted child (DISTINCT from orphan),
|
|
14
|
+
// acyclicity, store-wide slug uniqueness, module overlap. An ABSENT store root refuses.
|
|
15
|
+
//
|
|
16
|
+
// FAIL-CLOSED before every read. Containment is judged lexically AND by realpath over each
|
|
17
|
+
// path-bearing field, and the parents of a leaf are judged BEFORE the leaf is opened. The LEAF read
|
|
18
|
+
// itself is descriptor-bound and no-follow (the CLI half passes fs-read-nofollow.mjs in), so a
|
|
19
|
+
// pathname swapped after the probe cannot change the bytes judged. A mid-run PARENT swap is a
|
|
20
|
+
// STATED non-goal: this is an advisory checker, not a custody mechanism.
|
|
21
|
+
//
|
|
22
|
+
// No IO of its own: { read, probe, realpath, list } are injected. Dependency-free, Node >= 22.
|
|
23
|
+
|
|
24
|
+
import { dirname as pathDirname, isAbsolute, relative, sep } from 'node:path';
|
|
25
|
+
import { SPEC_SCHEMA, readSpecDocument, classifyPath } from '../references/scripts/spec-schema.mjs';
|
|
26
|
+
import { SPEC_OPS_GRAMMAR, buildClosure, listingParentOf, slugOf } from './spec-check-ops.mjs';
|
|
27
|
+
|
|
28
|
+
const STORE = SPEC_OPS_GRAMMAR.storePrefix;
|
|
29
|
+
const NAV = SPEC_OPS_GRAMMAR.navigator;
|
|
30
|
+
const STORE_ROOT = SPEC_OPS_GRAMMAR.storeRoot;
|
|
31
|
+
const STORE_DIR = STORE.slice(0, -1);
|
|
32
|
+
|
|
33
|
+
// The post-state each role declares. A listing parent declares NONE — it was not changed, it is read
|
|
34
|
+
// because something else was. `present` DOES declare one: the census already OBSERVED that document
|
|
35
|
+
// as a regular file, so a state that has changed underneath since is a fact to state, never a
|
|
36
|
+
// document to drop quietly from a store the run would otherwise call clean.
|
|
37
|
+
const DECLARED_STATE = Object.freeze({ add: 'file', modify: 'file', 'rename-to': 'file', remove: 'absent', 'rename-from': 'absent', present: 'file' });
|
|
38
|
+
const missing = () => {
|
|
39
|
+
throw new Error('spec-check: an IO dependency was not injected — this module owns no filesystem of its own');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const dirOf = (rel) => rel.slice(0, rel.lastIndexOf('/'));
|
|
43
|
+
const leafOf = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
|
|
44
|
+
const bare = (rel) => (rel.endsWith('/') ? rel.slice(0, -1) : rel);
|
|
45
|
+
const lineCount = (text) => text.replace(/\n$/, '').split('\n').length;
|
|
46
|
+
const occurrences = (text, needle) => text.split(needle).length - 1;
|
|
47
|
+
// Containment is a question about path COMPONENTS, and only the platform's own path model answers
|
|
48
|
+
// it. A textual prefix test reads "/repo\outside" as a child of "/repo" on a POSIX host — where the
|
|
49
|
+
// backslash is an ordinary filename character — and it mis-reads a filesystem root ("/" or "C:\")
|
|
50
|
+
// in both directions. `..` is compared as a whole SEGMENT, so a child named "..keep" stays inside.
|
|
51
|
+
const contained = (rootReal, real) => {
|
|
52
|
+
const rel = relative(rootReal, real);
|
|
53
|
+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// The link string a parent must carry for this child — verbatim, so `./x.md` and `./x/index.md`
|
|
57
|
+
// stay the distinct targets the 2a extraction froze them as.
|
|
58
|
+
const expectedLink = (rel, parent) => `./${rel.slice(parent.length - NAV.length)}`;
|
|
59
|
+
|
|
60
|
+
const verdictOf = (findings, documents, lane) => {
|
|
61
|
+
if (findings.length === 0) {
|
|
62
|
+
return { verdict: 'ACCEPT', exit: 0, findings, documents, lines: [`spec-check: ACCEPT — ${documents} document(s) clean (${lane})`] };
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
verdict: 'REFUSE',
|
|
66
|
+
exit: 1,
|
|
67
|
+
findings,
|
|
68
|
+
documents,
|
|
69
|
+
lines: [
|
|
70
|
+
`spec-check: REFUSE — ${findings.length} finding(s) over ${documents} document(s) (${lane})`,
|
|
71
|
+
...findings.map((f) => ` ${f.path}: ${f.rule} — ${f.message}`),
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
const refusal = (message) => ({ verdict: 'REFUSE', exit: 2, findings: [], documents: 0, lines: [`spec-check: REFUSE — ${message}`] });
|
|
76
|
+
|
|
77
|
+
// Every document of the closure, read ONCE: probe, then (only for a regular file) the descriptor-
|
|
78
|
+
// bound read and the reader verdict. Containment of the containing directory is decided BEFORE the
|
|
79
|
+
// read, so a directory that resolves outside the root is never opened through.
|
|
80
|
+
const readClosure = (closure, ctx) => {
|
|
81
|
+
const { io, at, rootReal, add } = ctx;
|
|
82
|
+
const docs = new Map();
|
|
83
|
+
for (const { path, roles } of closure) {
|
|
84
|
+
if (classifyPath(path) !== 'file' || !path.startsWith(STORE)) {
|
|
85
|
+
add('contained', path, 'the path is not a repo-relative file inside the store');
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const dirReal = io.realpath(at(dirOf(path)));
|
|
89
|
+
const dirContained = dirReal !== null && contained(rootReal, dirReal);
|
|
90
|
+
if (dirReal !== null && !dirContained) {
|
|
91
|
+
add('contained', path, `its directory ${dirOf(path)} resolves outside the root (${dirReal})`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const state = io.probe(at(path));
|
|
95
|
+
const doc = { path, roles, state, verdict: null, lines: 0, ground: [], edges: [] };
|
|
96
|
+
docs.set(path, doc);
|
|
97
|
+
const declared = roles.map((role) => DECLARED_STATE[role]).find(Boolean);
|
|
98
|
+
if (declared && state !== declared) {
|
|
99
|
+
const source = roles.includes('present')
|
|
100
|
+
? 'the census observed this document as a regular file'
|
|
101
|
+
: `the op declares this document ${declared === 'absent' ? 'gone' : 'present as a regular file'}`;
|
|
102
|
+
add('post-state', path, `${source}, the store now says "${state}"`);
|
|
103
|
+
}
|
|
104
|
+
if (state !== 'file') continue;
|
|
105
|
+
// Fail-closed is PROVEN contained, not "not proven to escape": a directory whose realpath does
|
|
106
|
+
// not resolve was never observed, so the leaf inside it is never opened.
|
|
107
|
+
if (!dirContained) {
|
|
108
|
+
add('contained', path, `its directory ${dirOf(path)} does not resolve, so the read is refused (fail closed)`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const read = io.read(at(path));
|
|
112
|
+
if (read.outcome !== 'ok') {
|
|
113
|
+
add('unreadable', path, `the descriptor-bound read says ${read.outcome}${read.className ? ` (${read.className})` : ''}${read.code ? ` (${read.code})` : ''} — the probe is not what is judged`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
doc.lines = lineCount(read.content);
|
|
117
|
+
doc.verdict = readSpecDocument(read.content, path.slice(STORE.length));
|
|
118
|
+
}
|
|
119
|
+
return docs;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const linkTargetsOf = (doc) => [...(doc?.verdict?.structure?.children ?? []), ...(doc?.verdict?.structure?.parts ?? [])].map((link) => link.target);
|
|
123
|
+
|
|
124
|
+
// Every LISTED edge is a claim about another document, and an unchecked claim is what lets a broken
|
|
125
|
+
// or escaping target ride into the reachability graph as if it were reached. Each edge is resolved,
|
|
126
|
+
// contained (lexically AND by realpath) and PROBED; only an observed regular file becomes an edge.
|
|
127
|
+
const judgeEdges = (doc, ctx) => {
|
|
128
|
+
const { io, at, rootReal, add } = ctx;
|
|
129
|
+
for (const target of linkTargetsOf(doc)) {
|
|
130
|
+
const child = `${dirOf(doc.path)}/${target.slice(2)}`;
|
|
131
|
+
if (classifyPath(child) !== 'file' || !child.startsWith(STORE)) {
|
|
132
|
+
add('link', doc.path, `it lists ${target}, which is not a document inside the store`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const real = io.realpath(at(child));
|
|
136
|
+
if (real === null || !contained(rootReal, real)) {
|
|
137
|
+
add('link', doc.path, `it lists ${target}, which ${real === null ? 'does not resolve' : `resolves outside the root (${real})`}`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const state = io.probe(at(child));
|
|
141
|
+
if (state !== 'file') {
|
|
142
|
+
add('link', doc.path, `it lists ${target}, which the store reports as "${state}" — a listed child is a regular file`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
doc.edges.push(child);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// The per-document judgement, identical in both lanes: the relayed reader rules, the kind's own
|
|
150
|
+
// line cap, the listed edges, the D4 scenario bindings and the containment of every path it names.
|
|
151
|
+
const judgeDocument = (doc, ctx) => {
|
|
152
|
+
const { io, at, rootReal, add } = ctx;
|
|
153
|
+
const { path, verdict } = doc;
|
|
154
|
+
if (verdict === null) return;
|
|
155
|
+
for (const error of verdict.errors) add('reader', path, `${error.rule}: ${error.message}`, { readerRule: error.rule });
|
|
156
|
+
const cap = SPEC_SCHEMA.maxLines[verdict.kind];
|
|
157
|
+
if (cap !== undefined && doc.lines > cap) {
|
|
158
|
+
add('threshold', path, `${doc.lines} lines over the ${verdict.kind} cap of ${cap} — promote it to <slug>/index.md + parts`);
|
|
159
|
+
}
|
|
160
|
+
const structure = verdict.structure;
|
|
161
|
+
if (structure === null) return;
|
|
162
|
+
// A module names the code the contract governs. Ground that is absent, unreadable or of the OTHER
|
|
163
|
+
// kind is a claim about code that is not there — and only a PROVEN canonical path is kept, so the
|
|
164
|
+
// store-wide overlap comparison never mixes observed ground with a lexical guess.
|
|
165
|
+
for (const claimed of structure.module?.paths ?? []) {
|
|
166
|
+
const wantDir = claimed.endsWith('/');
|
|
167
|
+
const target = bare(claimed);
|
|
168
|
+
const real = io.realpath(at(target));
|
|
169
|
+
if (real === null) {
|
|
170
|
+
add('module', path, `the module path ${claimed} names ground that is not there`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!contained(rootReal, real)) {
|
|
174
|
+
add('contained', path, `the module path ${claimed} resolves outside the root (${real})`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const state = io.probe(at(target));
|
|
178
|
+
if (state !== (wantDir ? 'dir' : 'file')) {
|
|
179
|
+
add('module', path, `the module path ${claimed} is "${state}", not the ${wantDir ? 'directory' : 'file'} it declares`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
doc.ground.push(real);
|
|
183
|
+
}
|
|
184
|
+
judgeEdges(doc, ctx);
|
|
185
|
+
for (const scenario of structure.scenarios) {
|
|
186
|
+
if (scenario.binding === null) continue;
|
|
187
|
+
const { file, marker } = scenario.binding;
|
|
188
|
+
if (classifyPath(file) !== 'file') {
|
|
189
|
+
add('binding', path, `S${scenario.ordinal} names "${file}", which is not a repo-relative file`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const real = io.realpath(at(file));
|
|
193
|
+
if (real === null) {
|
|
194
|
+
add('binding', path, `S${scenario.ordinal} binds ${file}, which does not resolve — an unobserved path is never opened`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!contained(rootReal, real)) {
|
|
198
|
+
add('contained', path, `the S${scenario.ordinal} binding ${file} resolves outside the root (${real})`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const state = io.probe(at(file));
|
|
202
|
+
if (state !== 'file') {
|
|
203
|
+
add('binding', path, `S${scenario.ordinal} binds ${file}, which the store reports as "${state}"`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const read = io.read(at(file));
|
|
207
|
+
if (read.outcome !== 'ok') {
|
|
208
|
+
add('binding', path, `S${scenario.ordinal} binds ${file}, which cannot be read (${read.outcome})`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const found = occurrences(read.content, marker);
|
|
212
|
+
if (found !== 1) add('binding', path, `the marker ${marker} occurs ${found} time(s) in ${file} — exactly once binds a scenario`);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// The listing judgement — a SESSION-lane rule: the document the ops touched must be listed by its
|
|
217
|
+
// parent exactly once after an add/modify/rename-to, and not at all after a remove/rename-from.
|
|
218
|
+
// Under --all the reachability invariants below own this ground instead, so it never doubles up.
|
|
219
|
+
const judgeListing = (doc, docs, add) => {
|
|
220
|
+
const declared = doc.roles.map((role) => DECLARED_STATE[role]).find(Boolean);
|
|
221
|
+
if (!declared) return;
|
|
222
|
+
const parent = listingParentOf(doc.path);
|
|
223
|
+
if (parent === null) return;
|
|
224
|
+
const holder = docs.get(parent);
|
|
225
|
+
const link = expectedLink(doc.path, parent);
|
|
226
|
+
if (!holder || holder.state !== 'file' || holder.verdict === null) {
|
|
227
|
+
add('listed', doc.path, `its listing parent ${parent} cannot be read, so nothing states whether ${link} is listed`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const found = linkTargetsOf(holder).filter((target) => target === link).length;
|
|
231
|
+
if (declared === 'file' && found !== 1) add('listed', doc.path, `${parent} lists ${link} ${found} time(s) — a present document is listed exactly once`);
|
|
232
|
+
if (declared === 'absent' && found !== 0) add('listed', doc.path, `${parent} still lists ${link} — a removed document is listed by nobody`);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Every `.md` document under the store, found by LISTING rather than by trusting any index. The walk
|
|
236
|
+
// is what makes "unlisted" and "orphan" observable at all — so a branch it could not observe is a
|
|
237
|
+
// FINDING, never an empty directory quietly walked past: an incomplete census that reported a clean
|
|
238
|
+
// store would be the one answer this lane must never give. A directory is contained BEFORE it is
|
|
239
|
+
// listed, and a non-regular `.md` sitting in the store is stated rather than skipped.
|
|
240
|
+
const walkStore = (ctx) => {
|
|
241
|
+
const { io, at, rootReal, add } = ctx;
|
|
242
|
+
const found = [];
|
|
243
|
+
const stack = [STORE_DIR];
|
|
244
|
+
while (stack.length > 0) {
|
|
245
|
+
const dir = stack.pop();
|
|
246
|
+
const dirReal = io.realpath(at(dir));
|
|
247
|
+
if (dirReal === null || !contained(rootReal, dirReal)) {
|
|
248
|
+
add('census', dir, `the store directory ${dirReal === null ? 'does not resolve' : `resolves outside the root (${dirReal})`}, so what it holds was never observed`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const names = io.list(at(dir));
|
|
252
|
+
if (names === null) {
|
|
253
|
+
add('census', dir, 'the store directory cannot be listed, so this branch of the census is unobserved');
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
for (const name of names) {
|
|
257
|
+
const rel = `${dir}/${name}`;
|
|
258
|
+
const state = io.probe(at(rel));
|
|
259
|
+
if (state === 'dir') stack.push(rel);
|
|
260
|
+
else if (state === 'file' && name.endsWith(SPEC_OPS_GRAMMAR.suffix)) found.push(rel);
|
|
261
|
+
// Every OTHER entry is stated, `.md` or not. A symlinked DIRECTORY is the hole a `.md`-only
|
|
262
|
+
// census leaves: an edge can be resolved THROUGH it while the documents behind it were never
|
|
263
|
+
// observed, read or judged — and --all would then accept a store it never fully saw.
|
|
264
|
+
else add('census', rel, `a ${state} sits inside the store — it holds regular ${SPEC_OPS_GRAMMAR.suffix} documents and plain directories only`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return found.sort();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Reachability from the store root over the extracted child/part links. Identity is the realpath of
|
|
271
|
+
// the containing DIRECTORY plus the leaf name — that is what makes a symlinked folder pointing at an
|
|
272
|
+
// ancestor a cycle rather than an infinite walk, and it is judged on the trail, not on the visited
|
|
273
|
+
// set, so a diamond (two indexes listing one document) is not mistaken for a loop.
|
|
274
|
+
const reachStore = (docs, ctx) => {
|
|
275
|
+
const { io, at } = ctx;
|
|
276
|
+
const reached = new Set();
|
|
277
|
+
const cycles = [];
|
|
278
|
+
const identity = (rel) => `${io.realpath(at(dirOf(rel))) ?? dirOf(rel)}/${leafOf(rel)}`;
|
|
279
|
+
const visit = (rel, trail) => {
|
|
280
|
+
const id = identity(rel);
|
|
281
|
+
if (trail.has(id)) {
|
|
282
|
+
cycles.push(rel);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (reached.has(rel)) return;
|
|
286
|
+
reached.add(rel);
|
|
287
|
+
const next = new Set([...trail, id]);
|
|
288
|
+
// PROVEN edges only (judgeEdges): a phantom target would launder an orphan into a reached
|
|
289
|
+
// document and hide exactly what this lane exists to find.
|
|
290
|
+
for (const child of docs.get(rel)?.edges ?? []) visit(child, next);
|
|
291
|
+
};
|
|
292
|
+
visit(STORE_ROOT, new Set());
|
|
293
|
+
return { reached, cycles };
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// The four cross-document invariants (the fifth — an absent store root — refuses the run before any
|
|
297
|
+
// of them can be asked). Each is a question no single document can answer about itself.
|
|
298
|
+
const judgeStore = (docs, ctx) => {
|
|
299
|
+
const { add } = ctx;
|
|
300
|
+
const { reached, cycles } = reachStore(docs, ctx);
|
|
301
|
+
for (const rel of cycles) add('acyclic', rel, 'the child graph reaches this document from inside itself — a cycle, not a tree');
|
|
302
|
+
for (const [rel, doc] of docs) {
|
|
303
|
+
if (reached.has(rel) || doc.state !== 'file') continue;
|
|
304
|
+
const parent = listingParentOf(rel);
|
|
305
|
+
if (parent !== null && reached.has(parent)) {
|
|
306
|
+
add('unlisted-child', rel, `${parent} is reached from the store root but does not list ${expectedLink(rel, parent)}`);
|
|
307
|
+
} else {
|
|
308
|
+
add('orphan', rel, 'no index reaches this document — it is not an unlisted child, it is outside the tree entirely');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const bySlug = new Map();
|
|
312
|
+
for (const [rel, doc] of docs) {
|
|
313
|
+
const slug = doc.state === 'file' ? slugOf(rel) : null;
|
|
314
|
+
if (slug === null || rel === STORE_ROOT) continue;
|
|
315
|
+
bySlug.set(slug, [...(bySlug.get(slug) ?? []), rel]);
|
|
316
|
+
}
|
|
317
|
+
for (const [slug, paths] of bySlug) {
|
|
318
|
+
if (paths.length > 1) for (const rel of paths) add('slug-unique', rel, `the slug "${slug}" is claimed by ${paths.length} documents (${paths.join(', ')}) — a slug is store-wide`);
|
|
319
|
+
}
|
|
320
|
+
// PROVEN canonical ground only (judgeDocument) — a path that never resolved is not compared as if
|
|
321
|
+
// it had. Nesting is asked with the platform's own path model — `dirname` walked upward, never a
|
|
322
|
+
// hand-rolled separator rule — and it is asked ONCE PER PATH rather than once per pair: the store
|
|
323
|
+
// this lane exists for holds a thousand specs, and a pairwise sweep would spend the whole D-scale
|
|
324
|
+
// budget re-deciding the same question a million times.
|
|
325
|
+
const owners = new Map();
|
|
326
|
+
for (const doc of docs.values()) {
|
|
327
|
+
for (const claimed of doc.ground) owners.set(claimed, new Set([...(owners.get(claimed) ?? []), doc.path]));
|
|
328
|
+
}
|
|
329
|
+
// The index accumulates unique conflicting PAIRS, which is the granularity a pairwise sweep
|
|
330
|
+
// reported: one finding per document per pair. Counting per ground path instead would repeat a
|
|
331
|
+
// pair once per descendant it shares, and a three-level chain would fuse unrelated pairs into one
|
|
332
|
+
// lumped message.
|
|
333
|
+
const pairs = new Map();
|
|
334
|
+
// The key is JSON, not a joined string: the census lists what is ON DISK, so a document path can
|
|
335
|
+
// carry any byte a filesystem allows — a space included — long before the reader refuses its slug.
|
|
336
|
+
// Any separator those paths might themselves contain makes the key non-injective, and the pair
|
|
337
|
+
// that collides would be dropped in silence.
|
|
338
|
+
const note = (one, other, claimed) => {
|
|
339
|
+
if (one === other) return;
|
|
340
|
+
const [a, b] = [one, other].sort();
|
|
341
|
+
const key = JSON.stringify([a, b]);
|
|
342
|
+
if (!pairs.has(key)) pairs.set(key, { a, b, claimed });
|
|
343
|
+
};
|
|
344
|
+
for (const [claimed, held] of owners) {
|
|
345
|
+
const owned = [...held];
|
|
346
|
+
for (const [i, one] of owned.entries()) for (const other of owned.slice(i + 1)) note(one, other, claimed);
|
|
347
|
+
for (let up = pathDirname(claimed), below = claimed; up !== below; below = up, up = pathDirname(up)) {
|
|
348
|
+
for (const above of owners.get(up) ?? []) for (const one of owned) note(one, above, claimed);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const { a, b, claimed } of pairs.values()) {
|
|
352
|
+
add('overlap', a, `its module ground overlaps ${claimed}, also claimed by ${b} — two contracts, one piece of code`);
|
|
353
|
+
add('overlap', b, `its module ground overlaps ${claimed}, also claimed by ${a} — two contracts, one piece of code`);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// checkSpecs({ root, ops, all }, deps) -> { verdict, exit, findings, documents, lines }.
|
|
358
|
+
// Exit 0 ACCEPT · 1 one or more findings · 2 a refusal about the RUN itself (an unresolvable root,
|
|
359
|
+
// an absent store root under --all) — the CLI prints the lines and returns the code.
|
|
360
|
+
export const checkSpecs = ({ root, ops = [], all = false } = {}, deps = {}) => {
|
|
361
|
+
const io = { read: missing, probe: missing, realpath: missing, list: missing, ...deps };
|
|
362
|
+
const at = (rel) => (rel === '' ? root : `${root}/${rel}`);
|
|
363
|
+
const rootReal = io.realpath(root);
|
|
364
|
+
if (rootReal === null) return refusal(`--root "${root}" does not resolve to a directory — nothing can be judged against it`);
|
|
365
|
+
const findings = [];
|
|
366
|
+
const add = (rule, path, message, extra = {}) => findings.push({ rule, path, message, ...extra });
|
|
367
|
+
const ctx = { io, at, rootReal, add };
|
|
368
|
+
if (all && io.probe(at(STORE_ROOT)) !== 'file') {
|
|
369
|
+
return refusal(`the store root ${STORE_ROOT} is not a regular file — an --all run over no store would report an empty clean store, so it refuses instead`);
|
|
370
|
+
}
|
|
371
|
+
const closure = all ? walkStore(ctx).map((path) => ({ path, roles: ['present'] })) : buildClosure(ops);
|
|
372
|
+
// A census that observed nothing still established one fact: its own refusal. Collapsing that into
|
|
373
|
+
// the empty-closure usage error would throw away the only thing the run learned, so the usage
|
|
374
|
+
// refusal is the SESSION lane's alone.
|
|
375
|
+
if (closure.length === 0 && findings.length === 0) return refusal('no document to judge — the op closure is empty');
|
|
376
|
+
const docs = readClosure(closure, ctx);
|
|
377
|
+
for (const doc of docs.values()) judgeDocument(doc, ctx);
|
|
378
|
+
if (all) judgeStore(docs, ctx);
|
|
379
|
+
else for (const doc of docs.values()) judgeListing(doc, docs, add);
|
|
380
|
+
return verdictOf(findings, closure.length, all ? 'the whole store' : `${ops.length} op(s)`);
|
|
381
|
+
};
|