@sabaiway/agent-workflow-kit 7.5.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 +47 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/procedures.md +2 -0
- package/tools/procedures.mjs +24 -4
- 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,53 @@ 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
|
+
|
|
7
54
|
## 7.5.0 — the reader pair joins the known-prior refresh lane; the mirrored reader gains the structure verdict (AD-114)
|
|
8
55
|
|
|
9
56
|
Slice 2b's `spec-check` will need a structural read of a spec document, and the reader is the ONE
|
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.
|
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
|
}
|
|
@@ -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
|
+
};
|