candor-ts 0.25.0 → 0.27.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/AGENTS.md +1 -1
- package/README.md +2 -2
- package/package.json +2 -2
- package/policy.mjs +91 -4
- package/query-core.mjs +29 -5
- package/query.mjs +150 -5
- package/scan-core.mjs +39 -0
- package/scan.mjs +532 -41
package/AGENTS.md
CHANGED
|
@@ -12,7 +12,7 @@ chains by hand.
|
|
|
12
12
|
> **Already installed? Report the version and ask before upgrading — before you scan.** If this
|
|
13
13
|
> project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
|
|
14
14
|
> global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
|
|
15
|
-
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.
|
|
15
|
+
> plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.27)."*
|
|
16
16
|
> On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
|
|
17
17
|
> `.candor/report*.json`, or `npm ls -g candor-ts`.
|
|
18
18
|
>
|
package/README.md
CHANGED
|
@@ -192,7 +192,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
|
|
|
192
192
|
| A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
|
|
193
193
|
| Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
|
|
194
194
|
| The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
|
|
195
|
-
| `{ candor: { version, toolchain, spec: "0.
|
|
195
|
+
| `{ candor: { version, toolchain, spec: "0.27" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
|
|
196
196
|
| Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
|
|
197
197
|
| The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
|
|
198
198
|
|
|
@@ -210,7 +210,7 @@ read the Rust source".
|
|
|
210
210
|
|
|
211
211
|
## Status
|
|
212
212
|
|
|
213
|
-
0.19.x, speaking candor-spec 0.
|
|
213
|
+
0.19.x, speaking candor-spec 0.27: the analysis core, the gate (`--policy` / `--gate-json` /
|
|
214
214
|
`.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
|
|
215
215
|
`--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
|
|
216
216
|
report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candor-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
|
+
"description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.27)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@types/node": "^25.9.2",
|
package/policy.mjs
CHANGED
|
@@ -796,6 +796,65 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
|
|
|
796
796
|
if (hit) push("AS-EFF-009", fn, [], `\`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``);
|
|
797
797
|
}
|
|
798
798
|
}
|
|
799
|
+
// ⟨0.27⟩ SPEC §4 — A RULE WHOSE SCOPE BOUND NO FUNCTION IS UNANSWERABLE, AND IS DISCLOSED RATHER THAN
|
|
800
|
+
// SCORED AS SATISFIED. Measured on this engine before the fix: `deny Fs orders` exits 1 on a real
|
|
801
|
+
// violation while `deny Fs ordrs` exits 0 in silence — a one-character typo in a layer name is a
|
|
802
|
+
// permanently green gate, and `unverified` then calls the layer "PROVABLY clean". The asymmetry is the
|
|
803
|
+
// tell: a typo'd EFFECT token already exits 2 naming the accepted vocabulary.
|
|
804
|
+
//
|
|
805
|
+
// Carried as a PROPERTY on the returned array rather than as a second return value, so every existing
|
|
806
|
+
// caller keeps working unchanged and `--gate-json` is untouched: JSON.stringify ignores non-index
|
|
807
|
+
// properties on an array, so the verdict document cannot acquire a field the spec has not pinned.
|
|
808
|
+
//
|
|
809
|
+
// A `deny`/`pure` with NO scope applies to every function and so can never be this kind of typo —
|
|
810
|
+
// excluded. A `forbid` counts a match on either endpoint. Counted over the same names the gate saw.
|
|
811
|
+
const zeroCount = new Map();
|
|
812
|
+
for (const r of pol.deny) if (r.scope) zeroCount.set(r.raw, 0);
|
|
813
|
+
for (const r of pol.forbid) zeroCount.set(r.raw, 0);
|
|
814
|
+
if (zeroCount.size) {
|
|
815
|
+
const names = new Set(functions.map((f) => f.fn));
|
|
816
|
+
for (const k of Object.keys(callgraph ?? {})) names.add(k);
|
|
817
|
+
for (const n of names) {
|
|
818
|
+
for (const r of pol.deny) if (r.scope && scopeMatches(n, r.scope)) zeroCount.set(r.raw, zeroCount.get(r.raw) + 1);
|
|
819
|
+
for (const r of pol.forbid) {
|
|
820
|
+
if (scopeMatches(n, r.from) || scopeMatches(n, r.to)) zeroCount.set(r.raw, zeroCount.get(r.raw) + 1);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// ⟨0.27⟩ CODE-POINT order, explicitly — the `zeroMatch` verdict key pins the `viaDispatchOn` collation
|
|
825
|
+
// (SPEC §4), and JS's default sort orders by UTF-16 code unit, which disagrees above the BMP. The raw
|
|
826
|
+
// line is built from user identifiers, so this is reachable rather than theoretical.
|
|
827
|
+
out.zeroMatch = [...zeroCount].filter(([, c]) => c === 0).map(([raw]) => raw)
|
|
828
|
+
.sort((a, b) => {
|
|
829
|
+
const ai = [...a], bi = [...b];
|
|
830
|
+
for (let i = 0; i < Math.min(ai.length, bi.length); i++) {
|
|
831
|
+
const d = ai[i].codePointAt(0) - bi[i].codePointAt(0);
|
|
832
|
+
if (d) return d;
|
|
833
|
+
}
|
|
834
|
+
return ai.length - bi.length;
|
|
835
|
+
});
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ⟨0.27⟩ EVERY RULE OF A REFUSED POLICY, one `unevaluated` entry per raw line (SPEC §3.1's
|
|
840
|
+
// composed-document clause; candor-java `unhonouredRules` is the model). `policyErrorUnevaluated` above
|
|
841
|
+
// names only the UNHONOURABLE lines — measured, that let a consumer read `deny Fs`, absent from the list
|
|
842
|
+
// on an exit-1 document, as evaluated-and-passed: a per-rule false all-clear arriving through the
|
|
843
|
+
// disclosure itself. The unhonourable lines keep their specific `why`; every other rule line carries the
|
|
844
|
+
// whole-policy refusal, because a policy is evaluated as a whole or not at all. ONE builder for both gate
|
|
845
|
+
// routes, for the same byte-equality reason as its siblings above.
|
|
846
|
+
export function policyRefusalUnevaluated(policyText, errors) {
|
|
847
|
+
const fatal = new Map(policyErrorUnevaluated(errors).map((e) => [e.rule, e.why]));
|
|
848
|
+
const out = [];
|
|
849
|
+
for (const raw of policyText.split(/\r?\n/)) {
|
|
850
|
+
const line = raw.split("#", 1)[0].trim();
|
|
851
|
+
if (!line) continue;
|
|
852
|
+
out.push({ rule: line,
|
|
853
|
+
why: fatal.get(line)
|
|
854
|
+
?? "NOT EVALUATED — a rule elsewhere in this policy cannot be honoured as written (named beside "
|
|
855
|
+
+ "its own entry in this list), and a policy is evaluated as a whole or not at all: a verdict "
|
|
856
|
+
+ "from its readable subset would be a verdict on a policy nobody wrote." });
|
|
857
|
+
}
|
|
799
858
|
return out;
|
|
800
859
|
}
|
|
801
860
|
|
|
@@ -813,7 +872,13 @@ export function discoverConfigPolicy(fromDir) {
|
|
|
813
872
|
for (;;) {
|
|
814
873
|
const cand = nodePath.join(dir, ".candor", "config");
|
|
815
874
|
if (fs.existsSync(cand)) {
|
|
816
|
-
|
|
875
|
+
// A config that EXISTS but cannot be READ is configured-but-unusable, which §3.4 makes exit 2 —
|
|
876
|
+
// never a silent "absent" and never an uncaught throw. The bare `readFileSync` here let an EACCES
|
|
877
|
+
// escape as an uncaught exception: node exits 1, which is the POLICY VIOLATION code, and the
|
|
878
|
+
// armed sentinel survived because nothing replaced it. Two wrong answers from one missing catch.
|
|
879
|
+
// The siblings below swallow to null, which is the other wrong answer — an unreadable pin or
|
|
880
|
+
// policy silently not enforced — so all three now refuse the same way.
|
|
881
|
+
const m = readConfigOrRefuse(cand).split(/\r?\n/)
|
|
817
882
|
.map((l) => l.split("#", 1)[0].trim()).filter(Boolean)
|
|
818
883
|
.map((l) => l.match(/^(\S+)\s*(.*)$/)).find((mm) => mm && mm[1].toLowerCase() === "policy");
|
|
819
884
|
if (!m) return null;
|
|
@@ -829,11 +894,11 @@ export function discoverConfigPolicy(fromDir) {
|
|
|
829
894
|
// nearest `.candor/config` walking UP, else null. Read-only + lenient (the caller decides fail-closed).
|
|
830
895
|
export function discoverConfigText(fromDir) {
|
|
831
896
|
const env = process.env.CANDOR_CONFIG;
|
|
832
|
-
if (env) {
|
|
897
|
+
if (env) { return readConfigOrRefuse(env, "CANDOR_CONFIG"); }
|
|
833
898
|
let dir = nodePath.resolve(fromDir);
|
|
834
899
|
for (;;) {
|
|
835
900
|
const cand = nodePath.join(dir, ".candor", "config");
|
|
836
|
-
if (fs.existsSync(cand)) {
|
|
901
|
+
if (fs.existsSync(cand)) { return readConfigOrRefuse(cand); }
|
|
837
902
|
const parent = nodePath.dirname(dir);
|
|
838
903
|
if (parent === dir) return null;
|
|
839
904
|
dir = parent;
|
|
@@ -846,9 +911,31 @@ export function discoverConfigText(fromDir) {
|
|
|
846
911
|
// see named anywhere in the output — can decide the verdict. That is the ambient-input failure this format
|
|
847
912
|
// exists to refuse, and the remedy is the usual one: not to forbid the input, but to make it unable to act
|
|
848
913
|
// unnamed. Same walk as `discoverConfigText`, deliberately, so the two cannot name different files.
|
|
914
|
+
/**
|
|
915
|
+
* Read a `.candor/config` that DISCOVERY has already found, or refuse (exit 2).
|
|
916
|
+
*
|
|
917
|
+
* The three readers in this file each handled an unreadable-but-present config differently: one let the
|
|
918
|
+
* exception escape (node exits 1 — the POLICY VIOLATION code — with a stack trace, and an armed
|
|
919
|
+
* `--gate-json` sentinel left in place), and two swallowed it to `null`, which reads as "no config" and
|
|
920
|
+
* silently drops whatever the file configured: a policy, a baseline, or an engine pin the operator
|
|
921
|
+
* believes is guarding them. §3.4's posture is the unreadable-policy one — configured-but-unusable
|
|
922
|
+
* fails loud — so all three route through here.
|
|
923
|
+
*/
|
|
924
|
+
function readConfigOrRefuse(p, via = null) {
|
|
925
|
+
try {
|
|
926
|
+
return fs.readFileSync(p, "utf8");
|
|
927
|
+
} catch (e) {
|
|
928
|
+
console.error(`candor-ts: ${via ? `${via}=` : ""}${p} exists but could not be read (${e.code ?? e.message}) `
|
|
929
|
+
+ `— failing (exit 2, unevaluable). A config that cannot be read is a guard the operator believes `
|
|
930
|
+
+ `is on: it may name a policy, a baseline or an engine pin, and treating it as absent would run `
|
|
931
|
+
+ `without them.`);
|
|
932
|
+
process.exit(2);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
849
936
|
export function discoverConfigPath(fromDir) {
|
|
850
937
|
const env = process.env.CANDOR_CONFIG;
|
|
851
|
-
if (env) {
|
|
938
|
+
if (env) { readConfigOrRefuse(env, "CANDOR_CONFIG"); return nodePath.resolve(env); }
|
|
852
939
|
let dir = nodePath.resolve(fromDir);
|
|
853
940
|
for (;;) {
|
|
854
941
|
const cand = nodePath.join(dir, ".candor", "config");
|
package/query-core.mjs
CHANGED
|
@@ -696,13 +696,37 @@ export const byCodePoint = (a, b) => {
|
|
|
696
696
|
};
|
|
697
697
|
|
|
698
698
|
// Reflexive+transitive subtype test over the hierarchy sidecar.
|
|
699
|
-
|
|
700
|
-
|
|
699
|
+
//
|
|
700
|
+
// ⟨0.26⟩ THREE-VALUED, because the format now distinguishes what it could not before. SPEC §2.2 makes the
|
|
701
|
+
// KEY SET the manifest: a producer emits a key for every type it indexed, `[]` included, so a type with NO
|
|
702
|
+
// key is one the pass never looked at. `hierarchy[t] ?? []` read those two cases alike and answered
|
|
703
|
+
// `false` — a positive claim about a type nobody analysed.
|
|
704
|
+
//
|
|
705
|
+
// MEASURED before the rung, doctoring only the sidecar of a real scan: removing the REACHING implementor's
|
|
706
|
+
// entry silently dropped the dispatcher from `possibleViaUnknownDispatch` (`[]` where the control gives
|
|
707
|
+
// `[Dispatcher.run]`), while removing the sidecar ENTIRELY left it correct — the ⟨0.24⟩ per-file rule
|
|
708
|
+
// over-lists. LESS information was SAFER than partial information. candor-java behaved identically, which
|
|
709
|
+
// is what said the defect was the FORMAT rather than either consumer.
|
|
710
|
+
//
|
|
711
|
+
// A POSITIVE DOMINATES: if a known path reaches `owner` the answer is YES even when another branch ran
|
|
712
|
+
// into an unindexed type — the relation is established and an unknown branch cannot un-establish it. NO is
|
|
713
|
+
// reserved for a walk that stayed entirely inside types the sidecar answers for.
|
|
714
|
+
function subtypeOf(type, owner, hierarchy) {
|
|
715
|
+
if (type === owner) return "YES";
|
|
716
|
+
let sawUnindexed = false;
|
|
701
717
|
const seen = new Set(), stack = [type];
|
|
702
718
|
while (stack.length) {
|
|
703
|
-
|
|
719
|
+
const cur = stack.pop();
|
|
720
|
+
if (!Object.prototype.hasOwnProperty.call(hierarchy, cur)) { sawUnindexed = true; continue; }
|
|
721
|
+
for (const s of hierarchy[cur]) { if (s === owner) return "YES"; if (!seen.has(s)) { seen.add(s); stack.push(s); } }
|
|
704
722
|
}
|
|
705
|
-
return
|
|
723
|
+
return sawUnindexed ? "UNANSWERABLE" : "NO";
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// The two-valued form. UNANSWERABLE collapses to TRUE — disclose, never drop — which is the direction
|
|
727
|
+
// §2.2 ⟨0.26⟩ requires and the opposite of what absence used to do.
|
|
728
|
+
function isSubtypeOf(type, owner, hierarchy) {
|
|
729
|
+
return subtypeOf(type, owner, hierarchy) !== "NO";
|
|
706
730
|
}
|
|
707
731
|
|
|
708
732
|
// callers + the unresolved-dispatch frontier (--include-unknown, SPEC §3.1/§4 0.7): the CONFIRMED set,
|
|
@@ -1236,7 +1260,7 @@ export function narrowingContext(fns, cg = {}, policyParsed = null) {
|
|
|
1236
1260
|
const u = byRaw.get(r.raw);
|
|
1237
1261
|
if (!u) continue; // unreachable: every held triple has a group
|
|
1238
1262
|
const cur = heldByFn.get(f.fn) ?? new Map();
|
|
1239
|
-
cur.set(`${r.raw}
|
|
1263
|
+
cur.set(`${r.raw}\0${eff}`, { fn: f.fn, rule: r.raw, effect: eff, why: u.why });
|
|
1240
1264
|
heldByFn.set(f.fn, cur);
|
|
1241
1265
|
}
|
|
1242
1266
|
return {
|
package/query.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
|
|
26
26
|
import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, discoverConfigText,
|
|
27
27
|
evaluatePolicy, reportNetClasses, resolveReasonClasses, discoverConfigPath,
|
|
28
|
-
policyVocabularyAnchor, policyErrorText,
|
|
28
|
+
policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable,
|
|
29
29
|
fatalPolicyErrors, refusalVerdict,
|
|
30
30
|
unanswerableScoped } from "./policy.mjs";
|
|
31
31
|
import { hasReport } from "./query-core.mjs";
|
|
@@ -232,7 +232,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
|
|
|
232
232
|
// package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
|
|
233
233
|
const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
234
234
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
|
|
235
|
-
const SPEC_VERSION = "0.
|
|
235
|
+
const SPEC_VERSION = "0.27";
|
|
236
236
|
|
|
237
237
|
// ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
|
|
238
238
|
// One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
|
|
@@ -437,9 +437,122 @@ function resolveGateVerb(rawArgs, { strict = false } = {}) {
|
|
|
437
437
|
// deprecated-alias machinery, because it has NO POSITIONALS: a stray argument is a usage error, never
|
|
438
438
|
// probed as a report or a policy. Kept out of parseCanonical for that reason — the peel helpers exist to
|
|
439
439
|
// accept the old grammar, and there is no old grammar for a verb introduced at ⟨0.24⟩.
|
|
440
|
+
// ── SPEC §3.3.1 ⟨0.27⟩ sink-arming helpers, shared by the gate verb. The scan entry point has its own
|
|
441
|
+
// copies (scan.mjs) because it must not import from this file; the RULES are the spec's, not shared code.
|
|
442
|
+
|
|
443
|
+
/** Learn `--gate-json` and `--policy` from a verb's argv with NO side effects. */
|
|
444
|
+
function preScanGateArgs(av) {
|
|
445
|
+
let gate = null, policy = null, report = null;
|
|
446
|
+
for (let i = 0; i < av.length; i++) {
|
|
447
|
+
const a = av[i], v = av[i + 1];
|
|
448
|
+
if (a !== "--gate-json" && a !== "--policy" && a !== "--report") continue;
|
|
449
|
+
if (v === undefined || (v.startsWith("-") && v !== "-")) continue;
|
|
450
|
+
if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v; else report = v;
|
|
451
|
+
i++;
|
|
452
|
+
}
|
|
453
|
+
return { gate, policy, report };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Artifact identity, not string identity — `--policy /w/P --gate-json ./P` from /w is one file. */
|
|
457
|
+
function sameArtifactPath(a, b) {
|
|
458
|
+
if (!a || !b || a === "-" || b === "-") return false;
|
|
459
|
+
const resolve = (p) => {
|
|
460
|
+
try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
|
|
461
|
+
try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); }
|
|
462
|
+
catch { return null; }
|
|
463
|
+
};
|
|
464
|
+
const x = resolve(a);
|
|
465
|
+
return x !== null && x === resolve(b);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The files the CWD-discovered \`.candor/config\` names, resolved as the loader resolves them. */
|
|
469
|
+
function configDeclaredInputs() {
|
|
470
|
+
const out = [];
|
|
471
|
+
try {
|
|
472
|
+
const disc = discoverConfigPolicy(process.cwd());
|
|
473
|
+
if (disc?.policyPath) out.push([disc.policyPath, "the config policy key"]);
|
|
474
|
+
const cfgPath = discoverConfigPath(process.cwd());
|
|
475
|
+
if (cfgPath) out.push([cfgPath, 'the discovered .candor/config']);
|
|
476
|
+
} catch { /* lenient: the real load refuses on its own terms */ }
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Refuse a sink that names an input of this run, having written nothing. */
|
|
481
|
+
function refuseGateJsonOverInput(gate, other, flag) {
|
|
482
|
+
if (!sameArtifactPath(gate, other)) return;
|
|
483
|
+
console.error(`candor-ts-query: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
|
|
484
|
+
+ `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy and `
|
|
485
|
+
+ `then gate on the wreckage. Nothing was written; give the verdict its own path.`);
|
|
486
|
+
process.exit(2);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** `.candor/config` is never a verdict sink, wherever it is. */
|
|
490
|
+
function refuseGateJsonAtConfig(gate) {
|
|
491
|
+
if (!gate || gate === "-") return;
|
|
492
|
+
const abs = path.resolve(gate);
|
|
493
|
+
if (path.basename(abs) !== "config" || path.basename(path.dirname(abs)) !== ".candor") return;
|
|
494
|
+
console.error(`candor-ts-query: --gate-json ${gate} is a .candor/config — refusing (exit 2). This would `
|
|
495
|
+
+ `destroy the config that configures this run. Nothing was written; give the verdict its own path.`);
|
|
496
|
+
process.exit(2);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Write the fail-closed refusal every later exit inherits unless a real verdict replaces it. */
|
|
500
|
+
function armQueryGateJson(p) {
|
|
501
|
+
try {
|
|
502
|
+
fs.writeFileSync(p, JSON.stringify(
|
|
503
|
+
refusalVerdict(SPEC_VERSION, "the gate did not complete — this document was written when the run "
|
|
504
|
+
+ "STARTED and was never replaced by a verdict, so the run failed, crashed or was killed before "
|
|
505
|
+
+ "it could decide. It is NOT a verdict about the code; see the run's stderr for the cause."),
|
|
506
|
+
null, 1) + "\n");
|
|
507
|
+
} catch (e) {
|
|
508
|
+
console.error(`candor-ts-query: could not arm --gate-json ${p} fail-closed (${e.message})`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
440
512
|
function resolveGateReportVerb(rawArgs) {
|
|
441
513
|
const usageLine = "usage: candor-ts-query gate --report <locator> --policy <file> [--json] [--gate-json <file>]";
|
|
442
514
|
let reportLocator = null, policyFile = null, gateJsonPath = null, json = false;
|
|
515
|
+
// SPEC §3.3.1 ⟨0.27⟩ — ARM FIRST, AND NEVER OVER AN INPUT. A pre-pass with no side effects, so both
|
|
516
|
+
// the collision refusal and the arming precede every exit in the loop below. See the note where the
|
|
517
|
+
// arming used to live for why the previous ordering was wrong.
|
|
518
|
+
{
|
|
519
|
+
const { gate, policy, report } = preScanGateArgs(rawArgs);
|
|
520
|
+
if (gate) {
|
|
521
|
+
refuseGateJsonOverInput(gate, policy, "--policy");
|
|
522
|
+
// §3.3.1 names "a report being read (`gate --report`)" as an input. Writing the verdict there
|
|
523
|
+
// destroys the very report the gate was asked to judge, and the diagnostic then blames the report
|
|
524
|
+
// rather than the collision.
|
|
525
|
+
refuseGateJsonOverInput(gate, report, "--report");
|
|
526
|
+
refuseGateJsonOverInput(gate, process.env.CANDOR_POLICY, "CANDOR_POLICY");
|
|
527
|
+
// THE CONFIG-DECLARED POLICY. This verb's policy ladder falls back to the \`policy\` key of the
|
|
528
|
+
// config discovered from the CWD, and the guard checked only the flags — so the checked-in form,
|
|
529
|
+
// which is the one a CI job has, was destroyed at exit 0 while the flag form refused. The same
|
|
530
|
+
// hole the scan route closed, one route across.
|
|
531
|
+
for (const [p2, label] of configDeclaredInputs()) refuseGateJsonOverInput(gate, p2, label);
|
|
532
|
+
refuseGateJsonAtConfig(gate);
|
|
533
|
+
if (gate !== "-") armQueryGateJson(gate);
|
|
534
|
+
// …AND THE STREAM'S ANALOG OF ARMING. `armQueryGateJson` writes a fail-closed placeholder to a
|
|
535
|
+
// FILE; a stream cannot hold one, so the equivalent is a hook that emits the refusal on any
|
|
536
|
+
// exit-2 path that has not already written a verdict.
|
|
537
|
+
//
|
|
538
|
+
// Without it, this verb exited 2 during ARGUMENT PARSING with stdout EMPTY, while the same verb
|
|
539
|
+
// refusing later from inside the gate streamed the document — the same operator mistake, two
|
|
540
|
+
// answers, decided by how early it was caught. A machine consumer reading an empty stream after
|
|
541
|
+
// exit 2 cannot tell it from a clean gate.
|
|
542
|
+
//
|
|
543
|
+
// Measured at the 0.27 go/no-go: java, swift and ts all had this hole on the `gate` verb, and
|
|
544
|
+
// PART 36's stream rows never reached it because every one of them runs the SCAN route. The row
|
|
545
|
+
// that catches it is now there.
|
|
546
|
+
else {
|
|
547
|
+
process.on("exit", (code) => {
|
|
548
|
+
if (code === 2 && !globalThis.__candorGateVerdictWritten) {
|
|
549
|
+
console.log(JSON.stringify(refusalVerdict(SPEC_VERSION,
|
|
550
|
+
"the gate did not complete — this run exited before a verdict could be produced", null), null, 1));
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
443
556
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
444
557
|
const a = rawArgs[i];
|
|
445
558
|
if (a === "--report") {
|
|
@@ -469,8 +582,18 @@ function resolveGateReportVerb(rawArgs) {
|
|
|
469
582
|
console.error(`candor-ts-query gate: unexpected argument '${a}' — \`gate\` takes no positionals; the report is a --report locator and the policy a --policy file\n ${usageLine}`);
|
|
470
583
|
process.exit(2);
|
|
471
584
|
}
|
|
585
|
+
// ARMING MOVED ABOVE THE FLAG LOOP (SPEC §3.3.1 ⟨0.27⟩).
|
|
586
|
+
//
|
|
587
|
+
// It used to sit here, and the comment justified it with a ⟨0.24⟩ ruling of my own: "a USAGE error was
|
|
588
|
+
// never a gate invocation, so it must write NOTHING". SPEC §3.3 says the opposite in terms — it names
|
|
589
|
+
// an unknown flag as a broken-gate-config exit-2 cause, and §3.1 adds that "if `--gate-json` was
|
|
590
|
+
// requested and the run exits 2 for ANY reason, a fail-closed document is written", calling a
|
|
591
|
+
// carve-out "a fail-open path with a reason attached". The ruling I built here was that carve-out, and
|
|
592
|
+
// the test pinning it pinned a reading the spec had already superseded. The stale green does not care
|
|
593
|
+
// that the operator's shell also failed.
|
|
594
|
+
const _policy = resolvePolicy(policyFile, null).policyFile;
|
|
472
595
|
const prefix = requireReport(reportLocator !== null ? locatorToPrefix(reportLocator) : discoverReportPrefix());
|
|
473
|
-
return { prefix, policyFile:
|
|
596
|
+
return { prefix, policyFile: _policy, gateJsonPath, json };
|
|
474
597
|
}
|
|
475
598
|
|
|
476
599
|
/**
|
|
@@ -1139,7 +1262,13 @@ switch (cmd) {
|
|
|
1139
1262
|
const gwrite = (obj) => {
|
|
1140
1263
|
const text = JSON.stringify(obj, null, 1);
|
|
1141
1264
|
for (const dest of gdests) {
|
|
1142
|
-
|
|
1265
|
+
// THE FLAG IS SET WHERE THE WRITE HAPPENS, not where the gate is entered. It was set on
|
|
1266
|
+
// entering this verb, under the comment "reaching here means the gate ran and will write its
|
|
1267
|
+
// own document" — which is a claim about the future, and false: the `--policy` fallback ladder
|
|
1268
|
+
// can still exit 2 below without writing. That suppressed the pre-pass hook and returned the
|
|
1269
|
+
// run to EMPTY stdout after exit 2, which is the exact channel the hook was added to close.
|
|
1270
|
+
// Caught by the second go/no-go panel; the first flag placement lasted about an hour.
|
|
1271
|
+
if (dest === "-") { globalThis.__candorGateVerdictWritten = true; console.log(text); continue; }
|
|
1143
1272
|
// A SURFACING side-output: an unwritable path is one stderr line, never a raw ENOENT crash whose
|
|
1144
1273
|
// exit 1 would read as a policy violation on a clean run (the scan path's rule).
|
|
1145
1274
|
try {
|
|
@@ -1182,7 +1311,9 @@ switch (cmd) {
|
|
|
1182
1311
|
// uses (SPEC §3.1 makes byte-equality between the two documents the acceptance test — the scan route
|
|
1183
1312
|
// needed this list so a dominating baseline regression could carry the refusal beside it, and a list on
|
|
1184
1313
|
// one route only would break the equality on the very change that repaired the precedence).
|
|
1185
|
-
|
|
1314
|
+
// ⟨0.27⟩ …listing EVERY rule of the refused policy, not only the unhonourable lines — the shared
|
|
1315
|
+
// builder with the scan route (SPEC §3.1's composed-document clause; byte-equality binds the two).
|
|
1316
|
+
if (gfatal.length) { const why = policyErrorText(policyFile, gfatal); console.error(why); grefuse(why, policyRefusalUnevaluated(gtext, gfatal)); }
|
|
1186
1317
|
// ⟨0.24⟩ THE CONFIG FILE THAT SUPPLIED VOCABULARY THE VERDICT USED (SPEC §3.1 `99eb4e9`) — named on a
|
|
1187
1318
|
// REFERENCE, not only on a firing, because the measured harm was a GREEN verdict a vocabulary file made
|
|
1188
1319
|
// green. Omitted when no alias was used, so every other verdict stays byte-identical to before.
|
|
@@ -1305,6 +1436,17 @@ switch (cmd) {
|
|
|
1305
1436
|
// Route the human output exactly as a scan does: to stderr whenever stdout carries the verdict
|
|
1306
1437
|
// document, so `candor-ts-query gate … --json | jq` sees pure JSON.
|
|
1307
1438
|
const gsay = (json || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
|
|
1439
|
+
// ⟨0.27⟩ SPEC §4 — THE ZERO-MATCH DISCLOSURE BELONGS ON THIS ROUTE TOO. Its absence was found by a
|
|
1440
|
+
// cross-engine differential: java and swift disclosed on `gate --report`, rust and ts did not, so
|
|
1441
|
+
// the same typo'd policy was reported by two engines and silently scored as satisfied by two. §4's
|
|
1442
|
+
// MUST carries no route qualifier, and this is the SUPPLY-CHAIN gate — a consumer pointing a policy
|
|
1443
|
+
// at a report someone else produced. ALWAYS on stderr, never through `gsay`: this is a disclosure
|
|
1444
|
+
// about the policy, not a verdict line, and stdout may be carrying the verdict document.
|
|
1445
|
+
for (const raw of gviol.zeroMatch ?? []) {
|
|
1446
|
+
console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
|
|
1447
|
+
+ `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
|
|
1448
|
+
+ `repos; a typo'd layer name otherwise.`);
|
|
1449
|
+
}
|
|
1308
1450
|
for (const x of gviol) gsay(`[${x.rule}] ${x.detail}`);
|
|
1309
1451
|
// ⟨0.21⟩ COMPLETENESS MANIFEST: a gate cannot be green over code candor never analyzed. The scan path
|
|
1310
1452
|
// exits 2 on its OWN `unanalyzed`; here the same manifest travels ON the report, so the same verdict
|
|
@@ -1325,6 +1467,9 @@ switch (cmd) {
|
|
|
1325
1467
|
if (gvocab) gverdictObj.policyVocabulary = gvocab;
|
|
1326
1468
|
gverdictObj.violations = gviol;
|
|
1327
1469
|
if (gunevaluated.length) gverdictObj.unevaluated = gunevaluated;
|
|
1470
|
+
// ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines above carry, in the machine channel,
|
|
1471
|
+
// in the same position the scan route puts it (§3.1's byte-equality MUST binds the two documents).
|
|
1472
|
+
if (gviol.zeroMatch?.length) gverdictObj.zeroMatch = gviol.zeroMatch;
|
|
1328
1473
|
if (gincomplete) { gverdictObj.incomplete = true; gverdictObj.unanalyzed = g.unanalyzed; }
|
|
1329
1474
|
if (g.coverage.length)
|
|
1330
1475
|
gverdictObj.coverage = { uncovered: g.coverage.length, packages: g.coverage.map((c) => c.name) };
|
package/scan-core.mjs
CHANGED
|
@@ -215,6 +215,45 @@ export const MODEL_SDK_RE =
|
|
|
215
215
|
export function isModelSdkPackage(moduleName) {
|
|
216
216
|
return MODEL_SDK_RE.test(moduleName);
|
|
217
217
|
}
|
|
218
|
+
/// SPEC §2 `fs` — for a call ALREADY classified `Fs`, the read/write direction its verb implies.
|
|
219
|
+
/// Returns ["read"], ["write"], ["read","write"], or [] when the verb does not say.
|
|
220
|
+
///
|
|
221
|
+
/// THE EMPTY CASE IS THE POINT. §2: "when `Fs` is reached but its kind is unknown … the field MUST be
|
|
222
|
+
/// omitted rather than guessed. An empty or partial `fs` would be read as a positive claim ('reads but
|
|
223
|
+
/// never writes'), which is the §4 trust contract's forbidden direction." So an unrecognised verb
|
|
224
|
+
/// contributes nothing and the field stays absent — absence means "kind undetermined", never "read-only".
|
|
225
|
+
///
|
|
226
|
+
/// A syntactic refinement of an effect candor already proved, NOT a soundness claim: a wrong direction
|
|
227
|
+
/// misreports a detail, a wrong EFFECT is the cardinal sin, and those are different failures. Deliberately
|
|
228
|
+
/// the same vocabulary and shape as candor-java's `fsKind` and candor-swift's — the surface is spec'd
|
|
229
|
+
/// four-way, and three engines inventing three verb tables for one field is how a shared field stops
|
|
230
|
+
/// meaning one thing. Node's sync/promise variants are handled by stripping the `Sync` suffix rather than
|
|
231
|
+
/// by listing every pair.
|
|
232
|
+
export function fsKind(moduleName, member) {
|
|
233
|
+
if (!member) return [];
|
|
234
|
+
const m = member.endsWith("Sync") ? member.slice(0, -4) : member;
|
|
235
|
+
// Reads the source AND writes the destination, in one call.
|
|
236
|
+
if (m === "copyFile" || m === "cp") return ["read", "write"];
|
|
237
|
+
const WRITE = new Set([
|
|
238
|
+
"writeFile", "appendFile", "write", "writev", "mkdir", "mkdtemp", "rmdir", "rm", "unlink",
|
|
239
|
+
"rename", "truncate", "ftruncate", "chmod", "fchmod", "lchmod", "chown", "fchown", "lchown",
|
|
240
|
+
"utimes", "futimes", "lutimes", "symlink", "link", "createWriteStream", "outputFile", "ensureDir",
|
|
241
|
+
"ensureFile", "emptyDir", "remove", "move", "outputJson", "writeJson", "writeJSON",
|
|
242
|
+
]);
|
|
243
|
+
const READ = new Set([
|
|
244
|
+
"readFile", "readdir", "read", "readv", "stat", "lstat", "fstat", "statfs", "access", "exists",
|
|
245
|
+
"realpath", "readlink", "createReadStream", "opendir", "watch", "watchFile", "readJson",
|
|
246
|
+
"readJSON", "pathExists", "lstatSync",
|
|
247
|
+
]);
|
|
248
|
+
if (WRITE.has(m)) return ["write"];
|
|
249
|
+
if (READ.has(m)) return ["read"];
|
|
250
|
+
// `open`/`openSync` take a MODE — "r", "w", "a" — so the verb alone does not say. Deliberately no claim
|
|
251
|
+
// rather than a guess at the common case.
|
|
252
|
+
if (m.startsWith("write") || m.startsWith("append")) return ["write"];
|
|
253
|
+
if (m.startsWith("read")) return ["read"];
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
218
257
|
export function kappa(moduleName, member) {
|
|
219
258
|
for (const [mre, vre, eff] of KAPPA_RULES) {
|
|
220
259
|
if (mre.test(moduleName) && (!vre || vre.test(member))) return eff;
|